Windows compatibility pass (beta target)
External editor via cmd/start + exe picker; Permissions hidden for Windows-local (chmod only flips read-only there); Explorer/Recycle Bin wording; Ctrl vs Cmd shortcut labels; separator-aware path joins + drive-root detection; drives list in the panel source picker; icon.ico + colored tray icon; icacls ACL on connections.json; README Windows (beta) section with upstream known issues and a smoke-test checklist. Runtime paths pending first boot on a real Windows machine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -173,15 +173,33 @@ Be careful when two panels reach the same data through different mount points.
|
|||||||
|
|
||||||
## Portability notes
|
## Portability notes
|
||||||
|
|
||||||
The codebase is platform-neutral except for two seams, both macOS-only today:
|
Platform seams are isolated behind `process.platform` / `PLATFORM` branches
|
||||||
|
marked with `PORTABILITY:` comments: editor launch (`open` / `cmd /c start` /
|
||||||
|
`xdg-open`), app pickers, owner/group lookups, tray images, window chrome,
|
||||||
|
shell-integration wording, drive listing, and the connections-file ACL. The
|
||||||
|
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
||||||
|
the in-app UI covers every menu action.
|
||||||
|
|
||||||
- **External editor launch** (`launchEditor` in [src/bun/edits.ts](src/bun/edits.ts)):
|
## Windows (beta)
|
||||||
uses macOS `open [-a app] file`. A Windows port should use
|
|
||||||
`cmd /c start "" [app] file`; Linux `xdg-open file` (or invoke the configured
|
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
||||||
editor binary directly). The `.app` picker in the `pickEditorApp` handler
|
electrobun APIs the app uses are implemented on Windows; known issues are
|
||||||
needs the equivalent executable picker per platform.
|
upstream:
|
||||||
- **Application menu** ([src/bun/menu.ts](src/bun/menu.ts)): electrobun menus
|
|
||||||
are macOS-only; the in-app UI covers every menu action.
|
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
|
||||||
|
WebView2 renders at 1x on scaled displays until upstream adds DPI awareness.
|
||||||
|
- **Notifications** use legacy balloon style, not Windows toast.
|
||||||
|
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
|
||||||
|
trigger SmartScreen/Mark-of-the-Web warnings on first run. No Start Menu
|
||||||
|
entry or uninstaller (installer unpacks to `%LOCALAPPDATA%`).
|
||||||
|
- `connections.json` is protected with an `icacls` current-user ACL instead of
|
||||||
|
POSIX 0600 (which is meaningless on Windows).
|
||||||
|
|
||||||
|
First-Windows-boot smoke test: drives dropdown lists C:\ etc.; navigate to a
|
||||||
|
drive root ('..' disappears); local↔remote transfers; external editor launch
|
||||||
|
(`cmd /c start`) and the .exe picker; Show in Explorer / Recycle Bin; tray icon
|
||||||
|
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
||||||
|
portable zip runs from any folder.
|
||||||
|
|
||||||
## Known issues
|
## Known issues
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -13,6 +13,7 @@
|
|||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
|
"png-to-ico": "^3.0.2",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -930,9 +931,9 @@
|
|||||||
|
|
||||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||||
|
|
||||||
"png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="],
|
"png-to-ico": ["png-to-ico@3.0.2", "", { "dependencies": { "@types/node": "^25.5.0", "minimist": "^1.2.8", "pngjs": "^7.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-36vvp3/YF7LPYkUEOj08/WgB1wI63qW391YfOhOWckgOtGkarr9+EhPBimcCmRP7fJaG4EaXQhTTaQ8qqdj8aA=="],
|
||||||
|
|
||||||
"pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
|
"postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
|
||||||
|
|
||||||
@@ -1144,6 +1145,8 @@
|
|||||||
|
|
||||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
|
"electrobun/png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="],
|
||||||
|
|
||||||
"hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
"hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||||
|
|
||||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||||
@@ -1166,7 +1169,7 @@
|
|||||||
|
|
||||||
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||||
|
|
||||||
"png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
|
"png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
|
||||||
|
|
||||||
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
@@ -1190,12 +1193,18 @@
|
|||||||
|
|
||||||
"@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
"@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||||
|
|
||||||
|
"electrobun/png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
|
||||||
|
|
||||||
|
"electrobun/png-to-ico/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||||
|
|
||||||
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||||
|
|
||||||
|
"png-to-ico/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
|
|
||||||
"sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
"sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export default {
|
|||||||
bundleCEF: false,
|
bundleCEF: false,
|
||||||
},
|
},
|
||||||
win: {
|
win: {
|
||||||
bundleCEF: false,
|
bundleCEF: false, // system WebView2 keeps the bundle small
|
||||||
|
icon: "assets/icon.ico",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies ElectrobunConfig;
|
} satisfies ElectrobunConfig;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/ssh2": "^1.15.5",
|
"@types/ssh2": "^1.15.5",
|
||||||
|
"png-to-ico": "^3.0.2",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
|
|||||||
+10
-3
@@ -1,10 +1,12 @@
|
|||||||
// Regenerate every raster icon asset from the SVG sources (dev-time only).
|
// Regenerate every raster icon asset from the SVG sources (dev-time only).
|
||||||
// bun scripts/gen-icons.ts
|
// bun scripts/gen-icons.ts
|
||||||
// Produces: assets/icon.png (1024), assets/icon.iconset/* (macOS app icon),
|
// Produces: assets/icon.png (1024), assets/icon.iconset/* (macOS app icon),
|
||||||
// assets/tray/tray-44-template.png.
|
// assets/icon.ico (Windows), assets/tray/tray-44-template.png (macOS tray),
|
||||||
|
// assets/tray/tray-32-color.png (Windows tray).
|
||||||
import { mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { Resvg } from "@resvg/resvg-js";
|
import { Resvg } from "@resvg/resvg-js";
|
||||||
|
import pngToIco from "png-to-ico";
|
||||||
|
|
||||||
const root = path.join(import.meta.dir, "..");
|
const root = path.join(import.meta.dir, "..");
|
||||||
const assets = path.join(root, "assets");
|
const assets = path.join(root, "assets");
|
||||||
@@ -27,8 +29,13 @@ for (const size of [16, 32, 128, 256, 512]) {
|
|||||||
await writePng(path.join(assets, "icon.iconset", `icon_${size}x${size}@2x.png`), logo, size * 2);
|
await writePng(path.join(assets, "icon.iconset", `icon_${size}x${size}@2x.png`), logo, size * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- tray base (22pt status item @2x) ----
|
// ---- Windows app icon (.ico: 16/32/48/256, used by installer + taskbar) ----
|
||||||
|
const icoPngs = [16, 32, 48, 256].map((size) => Buffer.from(renderPng(logo, size)));
|
||||||
|
await Bun.write(path.join(assets, "icon.ico"), await pngToIco(icoPngs));
|
||||||
|
|
||||||
|
// ---- tray: macOS 22pt template @2x; Windows small colored icon ----
|
||||||
const trayGlyph = await Bun.file(path.join(assets, "tray", "tray-glyph.svg")).text();
|
const trayGlyph = await Bun.file(path.join(assets, "tray", "tray-glyph.svg")).text();
|
||||||
await writePng(path.join(assets, "tray", "tray-44-template.png"), trayGlyph, 44);
|
await writePng(path.join(assets, "tray", "tray-44-template.png"), trayGlyph, 44);
|
||||||
|
await writePng(path.join(assets, "tray", "tray-32-color.png"), logo, 32);
|
||||||
|
|
||||||
console.log("icons regenerated: app iconset + tray template");
|
console.log("icons regenerated: app iconset + icon.ico + tray images");
|
||||||
|
|||||||
+19
-4
@@ -11,6 +11,16 @@ const COLUMNS: { key: ColumnKey; label: string }[] = [
|
|||||||
{ key: "group", label: "Group" },
|
{ key: "group", label: "Group" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// PORTABILITY: shell-integration wording per OS
|
||||||
|
const IS_WIN = process.platform === "win32";
|
||||||
|
const REVEAL_LABEL =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? "Reveal in Finder"
|
||||||
|
: IS_WIN
|
||||||
|
? "Show in Explorer"
|
||||||
|
: "Show in File Manager";
|
||||||
|
const TRASH_LABEL = IS_WIN ? "Move to Recycle Bin" : "Move to Trash";
|
||||||
|
|
||||||
export interface RowMenuContext {
|
export interface RowMenuContext {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -84,7 +94,7 @@ export class PanelContextMenu {
|
|||||||
items.push({ label: "Refresh", action: "ctx-refresh" });
|
items.push({ label: "Refresh", action: "ctx-refresh" });
|
||||||
if (ctx.isLocal) {
|
if (ctx.isLocal) {
|
||||||
items.push({ type: "separator" });
|
items.push({ type: "separator" });
|
||||||
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
|
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -99,18 +109,23 @@ export class PanelContextMenu {
|
|||||||
}
|
}
|
||||||
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
|
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
|
||||||
items.push({ label: "Rename", action: "ctx-rename" });
|
items.push({ label: "Rename", action: "ctx-rename" });
|
||||||
|
// chmod on Windows local files only flips the read-only bit — hide the lie
|
||||||
|
if (!(ctx.isLocal && IS_WIN)) {
|
||||||
items.push({ label: "Permissions", action: "ctx-chmod" });
|
items.push({ label: "Permissions", action: "ctx-chmod" });
|
||||||
|
}
|
||||||
items.push({ type: "separator" });
|
items.push({ type: "separator" });
|
||||||
items.push({ label: "Copy Path", action: "ctx-copy-path" });
|
items.push({ label: "Copy Path", action: "ctx-copy-path" });
|
||||||
if (ctx.isLocal) {
|
if (ctx.isLocal) {
|
||||||
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
|
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
|
||||||
}
|
}
|
||||||
items.push({ type: "separator" });
|
items.push({ type: "separator" });
|
||||||
if (ctx.isLocal) {
|
if (ctx.isLocal) {
|
||||||
items.push({
|
items.push({
|
||||||
label: "Move to Trash",
|
label: TRASH_LABEL,
|
||||||
action: "ctx-trash",
|
action: "ctx-trash",
|
||||||
tooltip: "Finder's Restore is not available for items trashed from here",
|
...(process.platform === "darwin"
|
||||||
|
? { tooltip: "Finder's Restore is not available for items trashed from here" }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
items.push({ label: "Delete", action: "ctx-delete" });
|
items.push({ label: "Delete", action: "ctx-delete" });
|
||||||
|
|||||||
+17
-6
@@ -48,14 +48,25 @@ export function writeFileText(
|
|||||||
* Launch a file in an external editor.
|
* Launch a file in an external editor.
|
||||||
*
|
*
|
||||||
* PORTABILITY: this is the only platform-specific piece of the edit feature.
|
* PORTABILITY: this is the only platform-specific piece of the edit feature.
|
||||||
* macOS delivers the file to any GUI app uniformly via `open [-a app]`.
|
* macOS delivers the file to any GUI app via `open [-a app]`; Windows via
|
||||||
* A Windows port should use `cmd /c start "" [app] file` and a Linux port
|
* `cmd /c start` (empty "" arg = window title slot); Linux via the app
|
||||||
* `xdg-open file` (or `app file` when an editor is configured) — swap the
|
* directly or `xdg-open`. Everything else (temp download, directory watch,
|
||||||
* spawn below and the .app picker in pickEditorApp; everything else
|
* auto-upload) is platform-neutral. Windows path pending first smoke test.
|
||||||
* (temp download, directory watch, auto-upload) is platform-neutral.
|
|
||||||
*/
|
*/
|
||||||
function launchEditor(filePath: string, editorApp: string): void {
|
function launchEditor(filePath: string, editorApp: string): void {
|
||||||
const args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath];
|
let args: string[];
|
||||||
|
switch (process.platform) {
|
||||||
|
case "darwin":
|
||||||
|
args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath];
|
||||||
|
break;
|
||||||
|
case "win32":
|
||||||
|
args = editorApp
|
||||||
|
? ["cmd", "/c", "start", "", editorApp, filePath]
|
||||||
|
: ["cmd", "/c", "start", "", filePath];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
args = editorApp ? [editorApp, filePath] : ["xdg-open", filePath];
|
||||||
|
}
|
||||||
Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
|
Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ function resolveId(kind: "user" | "group", id: number): string | undefined {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive roots for the Windows panel source picker ("C:\", "D:\", …).
|
||||||
|
* PORTABILITY: win32-only concept; empty elsewhere ("/" is always reachable).
|
||||||
|
*/
|
||||||
|
export function listLocalDrives(): string[] {
|
||||||
|
if (process.platform !== "win32") return [];
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawnSync(["fsutil", "fsinfo", "drives"], { stdout: "pipe", stderr: "ignore" });
|
||||||
|
if (!proc.success) return [];
|
||||||
|
// output: "Drives: C:\ D:\ ..."
|
||||||
|
return [...proc.stdout.toString().matchAll(/[A-Za-z]:\\/g)].map((m) => m[0]);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toEntry(name: string, full: string, st: Stats): FileEntry {
|
function toEntry(name: string, full: string, st: Stats): FileEntry {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
|
|||||||
+15
-3
@@ -3,6 +3,7 @@ import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
|||||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||||
import type { PanelContextMenu } from "./context-menu";
|
import type { PanelContextMenu } from "./context-menu";
|
||||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||||
|
import { listLocalDrives } from "./fs/local";
|
||||||
import type { SessionRegistry } from "./fs/registry";
|
import type { SessionRegistry } from "./fs/registry";
|
||||||
import { SftpProvider } from "./fs/sftp";
|
import { SftpProvider } from "./fs/sftp";
|
||||||
import type { ConnectionStore } from "./store/connections";
|
import type { ConnectionStore } from "./store/connections";
|
||||||
@@ -101,8 +102,9 @@ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void)
|
|||||||
const pickKeyFileHandler = () =>
|
const pickKeyFileHandler = () =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
const { homedir } = await import("node:os");
|
const { homedir } = await import("node:os");
|
||||||
|
const { join } = await import("node:path");
|
||||||
const picked = await Utils.openFileDialog({
|
const picked = await Utils.openFileDialog({
|
||||||
startingFolder: `${homedir()}/.ssh`,
|
startingFolder: join(homedir(), ".ssh"),
|
||||||
allowedFileTypes: "*",
|
allowedFileTypes: "*",
|
||||||
canChooseFiles: true,
|
canChooseFiles: true,
|
||||||
canChooseDirectory: false,
|
canChooseDirectory: false,
|
||||||
@@ -241,9 +243,17 @@ export function createAppRPC({
|
|||||||
|
|
||||||
pickEditorApp: () =>
|
pickEditorApp: () =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
|
// PORTABILITY: apps live in different places per OS
|
||||||
|
const { homedir } = await import("node:os");
|
||||||
|
const [startingFolder, allowedFileTypes] =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? ["/Applications", "app"]
|
||||||
|
: process.platform === "win32"
|
||||||
|
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
|
||||||
|
: [homedir(), "*"];
|
||||||
const picked = await Utils.openFileDialog({
|
const picked = await Utils.openFileDialog({
|
||||||
startingFolder: "/Applications",
|
startingFolder,
|
||||||
allowedFileTypes: "app",
|
allowedFileTypes,
|
||||||
canChooseFiles: true,
|
canChooseFiles: true,
|
||||||
canChooseDirectory: false,
|
canChooseDirectory: false,
|
||||||
allowsMultipleSelection: false,
|
allowsMultipleSelection: false,
|
||||||
@@ -301,6 +311,8 @@ export function createAppRPC({
|
|||||||
events.showAbout();
|
events.showAbout();
|
||||||
return {};
|
return {};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
listDrives: () => wrap(async () => ({ drives: listLocalDrives() })),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types";
|
import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types";
|
||||||
import { appDataDir } from "./paths";
|
import { appDataDir } from "./paths";
|
||||||
@@ -68,5 +69,23 @@ export class ConnectionStore {
|
|||||||
await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 });
|
await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 });
|
||||||
await fs.rename(tmp, this.file);
|
await fs.rename(tmp, this.file);
|
||||||
await fs.chmod(this.file, 0o600);
|
await fs.chmod(this.file, 0o600);
|
||||||
|
this.restrictOnWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PORTABILITY: 0o600 only toggles the read-only bit on Windows, so the
|
||||||
|
* plaintext passwords in this file would be world-readable there. Replace
|
||||||
|
* inheritance with a current-user-only ACL, best effort.
|
||||||
|
*/
|
||||||
|
private restrictOnWindows(): void {
|
||||||
|
if (process.platform !== "win32") return;
|
||||||
|
try {
|
||||||
|
Bun.spawn(
|
||||||
|
["icacls", this.file, "/inheritance:r", "/grant:r", `${os.userInfo().username}:F`],
|
||||||
|
{ stdout: "ignore", stderr: "ignore" },
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ACL tooling unavailable — file still works, just unprotected
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-8
@@ -1,21 +1,26 @@
|
|||||||
import { Tray, type BrowserWindow } from "electrobun/bun";
|
import { Tray, type BrowserWindow } from "electrobun/bun";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Menu-bar icon: the app glyph as a macOS template image (adapts to
|
* Menu-bar / notification-area icon. Clicking it brings the app window to the
|
||||||
* light/dark). Clicking it brings the app window to the front. No menu is ever
|
* front. No menu is ever set, so clicks arrive directly; no setImage swaps, so
|
||||||
* set, so clicks arrive directly; no setImage swaps, so the template
|
* the image treatment from construction always applies.
|
||||||
* treatment from construction always applies.
|
*
|
||||||
|
* PORTABILITY: macOS uses a monochrome template image (adapts to light/dark
|
||||||
|
* menu bars); Windows ignores `template` and wants a small colored icon.
|
||||||
*/
|
*/
|
||||||
export class AppTray {
|
export class AppTray {
|
||||||
private tray: Tray;
|
private tray: Tray;
|
||||||
|
|
||||||
constructor(win: BrowserWindow) {
|
constructor(win: BrowserWindow) {
|
||||||
|
const isMac = process.platform === "darwin";
|
||||||
this.tray = new Tray({
|
this.tray = new Tray({
|
||||||
title: "",
|
title: "",
|
||||||
image: "views://assets/tray/tray-44-template.png",
|
image: isMac
|
||||||
template: true,
|
? "views://assets/tray/tray-44-template.png"
|
||||||
width: 22,
|
: "views://assets/tray/tray-32-color.png",
|
||||||
height: 22,
|
template: isMac,
|
||||||
|
width: isMac ? 22 : 16,
|
||||||
|
height: isMac ? 22 : 16,
|
||||||
});
|
});
|
||||||
this.tray.on("tray-clicked", () => win.activate());
|
this.tray.on("tray-clicked", () => win.activate());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export type AppRPC = {
|
|||||||
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
|
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
|
||||||
/** Native About box (name, Build n, commit, engine versions). */
|
/** Native About box (name, Build n, commit, engine versions). */
|
||||||
showAbout: { params: Record<string, never>; response: Result };
|
showAbout: { params: Record<string, never>; response: Result };
|
||||||
|
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
||||||
|
listDrives: { params: Record<string, never>; response: Result<{ drives: string[] }> };
|
||||||
// editor windows
|
// editor windows
|
||||||
/** Main window only: open (or focus) a native editor window. */
|
/** Main window only: open (or focus) a native editor window. */
|
||||||
openEditor: { params: EditorOpenContext; response: Result };
|
openEditor: { params: EditorOpenContext; response: Result };
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button
|
<button
|
||||||
(click)="toggleWrap()"
|
(click)="toggleWrap()"
|
||||||
title="Wrap long lines — hides line numbers (⌥Z)"
|
[title]="'Wrap long lines — hides line numbers (' + altLabel + 'Z)'"
|
||||||
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
||||||
[class.bg-accent]="wrap()"
|
[class.bg-accent]="wrap()"
|
||||||
[class.text-foreground]="wrap()"
|
[class.text-foreground]="wrap()"
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
(click)="openFind()"
|
(click)="openFind()"
|
||||||
title="Find (⌘F)"
|
[title]="'Find (' + modLabel + 'F)'"
|
||||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaSearch" size="0.85rem" />
|
<ng-icon name="mynaSearch" size="0.85rem" />
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2">
|
<footer class="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2">
|
||||||
<span class="text-[11px] text-muted-foreground">⌘S save · ⌘F find · ⌘W close</span>
|
<span class="text-[11px] text-muted-foreground">{{ modLabel }}S save · {{ modLabel }}F find · {{ modLabel }}W close</span>
|
||||||
<span class="text-[11px] text-muted-foreground">·</span>
|
<span class="text-[11px] text-muted-foreground">·</span>
|
||||||
<span class="text-[11px] tabular-nums text-muted-foreground">
|
<span class="text-[11px] tabular-nums text-muted-foreground">
|
||||||
{{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }}
|
{{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
viewChild,
|
viewChild,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
import { NgIcon } from '@ng-icons/core';
|
||||||
import { blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME } from '../platform';
|
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
import { ToastService } from '../toast.service';
|
import { ToastService } from '../toast.service';
|
||||||
@@ -40,6 +40,8 @@ const DRAFT_DEBOUNCE_MS = 400;
|
|||||||
export class EditorWindow {
|
export class EditorWindow {
|
||||||
readonly ctx = EDITOR_CONTEXT;
|
readonly ctx = EDITOR_CONTEXT;
|
||||||
readonly isMacChrome = IS_MAC_CHROME;
|
readonly isMacChrome = IS_MAC_CHROME;
|
||||||
|
readonly modLabel = MOD_LABEL;
|
||||||
|
readonly altLabel = ALT_LABEL;
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly loadError = signal<string | null>(null);
|
readonly loadError = signal<string | null>(null);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FileEntry } from '@shared/types';
|
|||||||
import { DialogsService } from './dialogs.service';
|
import { DialogsService } from './dialogs.service';
|
||||||
import { joinPath } from './format';
|
import { joinPath } from './format';
|
||||||
import { type PanelState, type Side } from './panels.service';
|
import { type PanelState, type Side } from './panels.service';
|
||||||
|
import { IS_WINDOWS } from './platform';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
import { SettingsService } from './settings.service';
|
import { SettingsService } from './settings.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
@@ -157,6 +158,8 @@ export class FileActionsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
chmod(panel: PanelState): void {
|
chmod(panel: PanelState): void {
|
||||||
|
// Windows local chmod only flips the read-only bit — don't pretend
|
||||||
|
if (IS_WINDOWS && panel.sessionId() === 'local') return;
|
||||||
const targets = this.targets(panel);
|
const targets = this.targets(panel);
|
||||||
if (!targets.length) return;
|
if (!targets.length) return;
|
||||||
this.dialogs.chmod.set({
|
this.dialogs.chmod.set({
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
>
|
>
|
||||||
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
|
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
|
||||||
</button>
|
</button>
|
||||||
|
@if (chmodAvailable()) {
|
||||||
<button
|
<button
|
||||||
(click)="run('chmod')"
|
(click)="run('chmod')"
|
||||||
title="Permissions"
|
title="Permissions"
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
>
|
>
|
||||||
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
(click)="run('delete')"
|
(click)="run('delete')"
|
||||||
title="Delete (F8 / Del)"
|
title="Delete (F8 / Del)"
|
||||||
@@ -95,6 +97,17 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Windows drive roots (empty list elsewhere) -->
|
||||||
|
@for (drive of drives(); track drive) {
|
||||||
|
<button
|
||||||
|
(click)="pickDrive(drive)"
|
||||||
|
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="flex-1 font-mono">{{ drive }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
|
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
|
||||||
<div class="my-1 border-t border-border"></div>
|
<div class="my-1 border-t border-border"></div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { FileActionsService } from '../file-actions.service';
|
|||||||
import { fileGlyph, fileIconClass, fileKind } from '../file-icons';
|
import { fileGlyph, fileIconClass, fileKind } from '../file-icons';
|
||||||
import { formatDate, formatMode, formatSize } from '../format';
|
import { formatDate, formatMode, formatSize } from '../format';
|
||||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||||
|
import { IS_WINDOWS } from '../platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
import { TransfersService } from '../transfers.service';
|
import { TransfersService } from '../transfers.service';
|
||||||
@@ -56,6 +57,10 @@ export class FilePanel {
|
|||||||
|
|
||||||
readonly pathDraft = signal('');
|
readonly pathDraft = signal('');
|
||||||
readonly menuOpen = signal(false);
|
readonly menuOpen = signal(false);
|
||||||
|
/** Windows drive roots for the source picker; always empty elsewhere. */
|
||||||
|
readonly drives = signal<string[]>([]);
|
||||||
|
/** chmod on Windows local files only flips the read-only bit — hide it. */
|
||||||
|
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
|
||||||
|
|
||||||
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
||||||
private lastClickedIndex = 0;
|
private lastClickedIndex = 0;
|
||||||
@@ -79,7 +84,18 @@ export class FilePanel {
|
|||||||
void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() });
|
void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pickDrive(drive: string): void {
|
||||||
|
this.menuOpen.set(false);
|
||||||
|
this.activate();
|
||||||
|
void this.panel().load(drive);
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
if (IS_WINDOWS && this.rpc.available) {
|
||||||
|
void this.rpc.request.listDrives({}).then((res) => {
|
||||||
|
if (res.ok) this.drives.set(res.drives);
|
||||||
|
});
|
||||||
|
}
|
||||||
// keep the editable path bar in sync with the actual cwd
|
// keep the editable path bar in sync with the actual cwd
|
||||||
effect(() => this.pathDraft.set(this.panel().cwd()));
|
effect(() => this.pathDraft.set(this.panel().cwd()));
|
||||||
// focus follows the active side (Tab switches panels, dialogs refocus on close)
|
// focus follows the active side (Tab switches panels, dialogs refocus on close)
|
||||||
|
|||||||
+11
-2
@@ -42,9 +42,18 @@ export function formatEta(seconds: number): string {
|
|||||||
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
|
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POSIX-style join for UI-composed paths (both panels use "/" on macOS + remote). */
|
/**
|
||||||
|
* Separator-aware join for UI-composed paths: remote and POSIX-local dirs use
|
||||||
|
* "/", a Windows-local dir ("C:\Users\x") keeps "\" so paths stay consistent.
|
||||||
|
*/
|
||||||
export function joinPath(dir: string, name: string): string {
|
export function joinPath(dir: string, name: string): string {
|
||||||
return dir.endsWith('/') ? dir + name : `${dir}/${name}`;
|
const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
|
||||||
|
return dir.endsWith(sep) ? dir + name : `${dir}${sep}${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "/" (POSIX) or a drive root like "C:\" — nothing above to navigate to. */
|
||||||
|
export function isRootPath(p: string): boolean {
|
||||||
|
return p === '/' || /^[A-Za-z]:[\\/]?$/.test(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 0o755 -> "rwxr-xr-x" */
|
/** 0o755 -> "rwxr-xr-x" */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable, computed, inject, signal, type WritableSignal } from '@angu
|
|||||||
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
||||||
import { fileKind } from './file-icons';
|
import { fileKind } from './file-icons';
|
||||||
import { FocusService } from './focus.service';
|
import { FocusService } from './focus.service';
|
||||||
import { formatMode } from './format';
|
import { formatMode, isRootPath, joinPath } from './format';
|
||||||
import { RpcService } from './rpc.service';
|
import { RpcService } from './rpc.service';
|
||||||
import { ToastService } from './toast.service';
|
import { ToastService } from './toast.service';
|
||||||
|
|
||||||
@@ -73,8 +73,8 @@ export class PanelState {
|
|||||||
return cmp * dir;
|
return cmp * dir;
|
||||||
});
|
});
|
||||||
const cwd = this.cwd();
|
const cwd = this.cwd();
|
||||||
if (cwd && cwd !== '/') {
|
if (cwd && !isRootPath(cwd)) {
|
||||||
list.unshift({ name: '..', path: `${cwd}/..`, type: 'dir', size: 0, mtime: 0, mode: 0 });
|
list.unshift({ name: '..', path: joinPath(cwd, '..'), type: 'dir', size: 0, mtime: 0, mode: 0 });
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
@@ -144,7 +144,7 @@ export class PanelState {
|
|||||||
|
|
||||||
up(): void {
|
up(): void {
|
||||||
if (!this.cwd()) return;
|
if (!this.cwd()) return;
|
||||||
void this.load(`${this.cwd()}/..`);
|
void this.load(joinPath(this.cwd(), '..'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update the filter and park the cursor on the first real match. */
|
/** Update the filter and park the cursor on the first real match. */
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?
|
|||||||
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
|
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
|
||||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
||||||
|
|
||||||
|
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||||
|
|
||||||
|
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||||
|
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
|
||||||
|
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
|
||||||
|
|
||||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
||||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { NgIcon } from '@ng-icons/core';
|
|||||||
import { BRANDING } from '@shared/branding';
|
import { BRANDING } from '@shared/branding';
|
||||||
import type { AccentTone } from '@shared/types';
|
import type { AccentTone } from '@shared/types';
|
||||||
import { PanelsService } from '../panels.service';
|
import { PanelsService } from '../panels.service';
|
||||||
import { IS_MAC_CHROME } from '../platform';
|
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||||
import { RpcService } from '../rpc.service';
|
import { RpcService } from '../rpc.service';
|
||||||
import { SettingsService } from '../settings.service';
|
import { SettingsService } from '../settings.service';
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
@if (panels.workspaces().length > 1) {
|
@if (panels.workspaces().length > 1) {
|
||||||
<button
|
<button
|
||||||
(click)="close($event, ws.id)"
|
(click)="close($event, ws.id)"
|
||||||
title="Close tab (⌘W)"
|
[title]="'Close tab (' + modLabel + 'W)'"
|
||||||
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
|
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaX" size="0.7rem" />
|
<ng-icon name="mynaX" size="0.7rem" />
|
||||||
@@ -94,7 +94,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
(click)="panels.newWorkspace()"
|
(click)="panels.newWorkspace()"
|
||||||
title="New tab (⌘T)"
|
[title]="'New tab (' + modLabel + 'T)'"
|
||||||
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ng-icon name="mynaPlus" size=".8rem" />
|
<ng-icon name="mynaPlus" size=".8rem" />
|
||||||
@@ -359,6 +359,7 @@ export class TabBar {
|
|||||||
private readonly rpc = inject(RpcService);
|
private readonly rpc = inject(RpcService);
|
||||||
readonly settingsOpen = signal(false);
|
readonly settingsOpen = signal(false);
|
||||||
readonly isMacChrome = IS_MAC_CHROME;
|
readonly isMacChrome = IS_MAC_CHROME;
|
||||||
|
readonly modLabel = MOD_LABEL;
|
||||||
readonly appName = BRANDING.appName;
|
readonly appName = BRANDING.appName;
|
||||||
readonly buildLabel = BRANDING.buildLabel;
|
readonly buildLabel = BRANDING.buildLabel;
|
||||||
readonly themes = THEMES;
|
readonly themes = THEMES;
|
||||||
|
|||||||
Reference in New Issue
Block a user