diff --git a/README.md b/README.md index 926a8ec..0e81e79 100644 --- a/README.md +++ b/README.md @@ -173,15 +173,33 @@ Be careful when two panels reach the same data through different mount points. ## 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)): - uses macOS `open [-a app] file`. A Windows port should use - `cmd /c start "" [app] file`; Linux `xdg-open file` (or invoke the configured - editor binary directly). The `.app` picker in the `pickEditorApp` handler - needs the equivalent executable picker per platform. -- **Application menu** ([src/bun/menu.ts](src/bun/menu.ts)): electrobun menus - are macOS-only; the in-app UI covers every menu action. +## Windows (beta) + +Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All +electrobun APIs the app uses are implemented on Windows; known issues are +upstream: + +- **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 diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000..58fbb4b Binary files /dev/null and b/assets/icon.ico differ diff --git a/assets/tray/tray-32-color.png b/assets/tray/tray-32-color.png new file mode 100644 index 0000000..9d87236 Binary files /dev/null and b/assets/tray/tray-32-color.png differ diff --git a/bun.lock b/bun.lock index c87fc44..98aa74f 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "@resvg/resvg-js": "^2.6.2", "@types/bun": "latest", "@types/ssh2": "^1.15.5", + "png-to-ico": "^3.0.2", "typescript": "~5.9.3", }, }, @@ -930,9 +931,9 @@ "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=="], @@ -1144,6 +1145,8 @@ "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=="], "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=="], - "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=="], @@ -1190,12 +1193,18 @@ "@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-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "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=="], "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], diff --git a/electrobun.config.ts b/electrobun.config.ts index 18930be..40798eb 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -27,7 +27,8 @@ export default { bundleCEF: false, }, win: { - bundleCEF: false, + bundleCEF: false, // system WebView2 keeps the bundle small + icon: "assets/icon.ico", }, }, } satisfies ElectrobunConfig; diff --git a/package.json b/package.json index 0ed5567..496656c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@resvg/resvg-js": "^2.6.2", "@types/bun": "latest", "@types/ssh2": "^1.15.5", + "png-to-ico": "^3.0.2", "typescript": "~5.9.3" }, "trustedDependencies": [ diff --git a/scripts/gen-icons.ts b/scripts/gen-icons.ts index 273bb21..ee7df9b 100644 --- a/scripts/gen-icons.ts +++ b/scripts/gen-icons.ts @@ -1,10 +1,12 @@ // Regenerate every raster icon asset from the SVG sources (dev-time only). // bun scripts/gen-icons.ts // 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 path from "node:path"; import { Resvg } from "@resvg/resvg-js"; +import pngToIco from "png-to-ico"; const root = path.join(import.meta.dir, ".."); 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); } -// ---- 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(); 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"); diff --git a/src/bun/context-menu.ts b/src/bun/context-menu.ts index 647985e..8e0f05b 100644 --- a/src/bun/context-menu.ts +++ b/src/bun/context-menu.ts @@ -11,6 +11,16 @@ const COLUMNS: { key: ColumnKey; label: string }[] = [ { 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 { path: string; name: string; @@ -84,7 +94,7 @@ export class PanelContextMenu { items.push({ label: "Refresh", action: "ctx-refresh" }); if (ctx.isLocal) { items.push({ type: "separator" }); - items.push({ label: "Reveal in Finder", action: "ctx-reveal" }); + items.push({ label: REVEAL_LABEL, action: "ctx-reveal" }); } return items; } @@ -99,18 +109,23 @@ export class PanelContextMenu { } items.push({ label: "Copy to Other Panel", action: "ctx-copy" }); items.push({ label: "Rename", action: "ctx-rename" }); - items.push({ label: "Permissions", action: "ctx-chmod" }); + // 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({ type: "separator" }); items.push({ label: "Copy Path", action: "ctx-copy-path" }); if (ctx.isLocal) { - items.push({ label: "Reveal in Finder", action: "ctx-reveal" }); + items.push({ label: REVEAL_LABEL, action: "ctx-reveal" }); } items.push({ type: "separator" }); if (ctx.isLocal) { items.push({ - label: "Move to Trash", + label: TRASH_LABEL, 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" }); diff --git a/src/bun/edits.ts b/src/bun/edits.ts index d0592f8..dec7186 100644 --- a/src/bun/edits.ts +++ b/src/bun/edits.ts @@ -48,14 +48,25 @@ export function writeFileText( * Launch a file in an external editor. * * 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]`. - * A Windows port should use `cmd /c start "" [app] file` and a Linux port - * `xdg-open file` (or `app file` when an editor is configured) — swap the - * spawn below and the .app picker in pickEditorApp; everything else - * (temp download, directory watch, auto-upload) is platform-neutral. + * macOS delivers the file to any GUI app via `open [-a app]`; Windows via + * `cmd /c start` (empty "" arg = window title slot); Linux via the app + * directly or `xdg-open`. Everything else (temp download, directory watch, + * auto-upload) is platform-neutral. Windows path pending first smoke test. */ 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" }); } diff --git a/src/bun/fs/local.ts b/src/bun/fs/local.ts index e0b036d..9d4aeba 100644 --- a/src/bun/fs/local.ts +++ b/src/bun/fs/local.ts @@ -47,6 +47,22 @@ function resolveId(kind: "user" | "group", id: number): string | undefined { 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 { return { name, diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index d8f177a..ae3ff7d 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -3,6 +3,7 @@ import type { AppRPC, EditorOpenContext } from "../shared/rpc"; import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types"; import type { PanelContextMenu } from "./context-menu"; import { readFileText, writeFileText, type EditManager } from "./edits"; +import { listLocalDrives } from "./fs/local"; import type { SessionRegistry } from "./fs/registry"; import { SftpProvider } from "./fs/sftp"; import type { ConnectionStore } from "./store/connections"; @@ -101,8 +102,9 @@ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void) const pickKeyFileHandler = () => wrap(async () => { const { homedir } = await import("node:os"); + const { join } = await import("node:path"); const picked = await Utils.openFileDialog({ - startingFolder: `${homedir()}/.ssh`, + startingFolder: join(homedir(), ".ssh"), allowedFileTypes: "*", canChooseFiles: true, canChooseDirectory: false, @@ -241,9 +243,17 @@ export function createAppRPC({ pickEditorApp: () => 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({ - startingFolder: "/Applications", - allowedFileTypes: "app", + startingFolder, + allowedFileTypes, canChooseFiles: true, canChooseDirectory: false, allowsMultipleSelection: false, @@ -301,6 +311,8 @@ export function createAppRPC({ events.showAbout(); return {}; }), + + listDrives: () => wrap(async () => ({ drives: listLocalDrives() })), }, }, }); diff --git a/src/bun/store/connections.ts b/src/bun/store/connections.ts index 88ee3b0..5c71572 100644 --- a/src/bun/store/connections.ts +++ b/src/bun/store/connections.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types"; 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.rename(tmp, this.file); 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 + } } } diff --git a/src/bun/tray.ts b/src/bun/tray.ts index 167d2a0..a906839 100644 --- a/src/bun/tray.ts +++ b/src/bun/tray.ts @@ -1,21 +1,26 @@ import { Tray, type BrowserWindow } from "electrobun/bun"; /** - * Menu-bar icon: the app glyph as a macOS template image (adapts to - * light/dark). Clicking it brings the app window to the front. No menu is ever - * set, so clicks arrive directly; no setImage swaps, so the template - * treatment from construction always applies. + * Menu-bar / notification-area icon. Clicking it brings the app window to the + * front. No menu is ever set, so clicks arrive directly; no setImage swaps, so + * the image 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 { private tray: Tray; constructor(win: BrowserWindow) { + const isMac = process.platform === "darwin"; this.tray = new Tray({ title: "", - image: "views://assets/tray/tray-44-template.png", - template: true, - width: 22, - height: 22, + image: isMac + ? "views://assets/tray/tray-44-template.png" + : "views://assets/tray/tray-32-color.png", + template: isMac, + width: isMac ? 22 : 16, + height: isMac ? 22 : 16, }); this.tray.on("tray-clicked", () => win.activate()); } diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index dbf65b1..6909dd1 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -139,6 +139,8 @@ export type AppRPC = { showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result }; /** Native About box (name, Build n, commit, engine versions). */ showAbout: { params: Record; response: Result }; + /** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */ + listDrives: { params: Record; response: Result<{ drives: string[] }> }; // editor windows /** Main window only: open (or focus) a native editor window. */ openEditor: { params: EditorOpenContext; response: Result }; diff --git a/ui/src/app/editor-window/editor-window.html b/ui/src/app/editor-window/editor-window.html index 54b9ed5..94278c3 100644 --- a/ui/src/app/editor-window/editor-window.html +++ b/ui/src/app/editor-window/editor-window.html @@ -28,7 +28,7 @@
- + @if (chmodAvailable()) { + + } + } + @if (connections.grouped().loose.length || connections.grouped().stacks.length) {
} diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index b6e4186..6294e5b 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -16,6 +16,7 @@ import { FileActionsService } from '../file-actions.service'; import { fileGlyph, fileIconClass, fileKind } from '../file-icons'; import { formatDate, formatMode, formatSize } from '../format'; import { PanelsService, type Side, type SortKey } from '../panels.service'; +import { IS_WINDOWS } from '../platform'; import { RpcService } from '../rpc.service'; import { SettingsService } from '../settings.service'; import { TransfersService } from '../transfers.service'; @@ -56,6 +57,10 @@ export class FilePanel { readonly pathDraft = signal(''); readonly menuOpen = signal(false); + /** Windows drive roots for the source picker; always empty elsewhere. */ + readonly drives = signal([]); + /** 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>('list'); private lastClickedIndex = 0; @@ -79,7 +84,18 @@ export class FilePanel { 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() { + 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 effect(() => this.pathDraft.set(this.panel().cwd())); // focus follows the active side (Tab switches panels, dialogs refocus on close) diff --git a/ui/src/app/format.ts b/ui/src/app/format.ts index 56611cf..e7c287e 100644 --- a/ui/src/app/format.ts +++ b/ui/src/app/format.ts @@ -42,9 +42,18 @@ export function formatEta(seconds: number): string { 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 { - 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" */ diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index 55e0587..373b798 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -2,7 +2,7 @@ import { Injectable, computed, inject, signal, type WritableSignal } from '@angu import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types'; import { fileKind } from './file-icons'; import { FocusService } from './focus.service'; -import { formatMode } from './format'; +import { formatMode, isRootPath, joinPath } from './format'; import { RpcService } from './rpc.service'; import { ToastService } from './toast.service'; @@ -73,8 +73,8 @@ export class PanelState { return cmp * dir; }); const cwd = this.cwd(); - if (cwd && cwd !== '/') { - list.unshift({ name: '..', path: `${cwd}/..`, type: 'dir', size: 0, mtime: 0, mode: 0 }); + if (cwd && !isRootPath(cwd)) { + list.unshift({ name: '..', path: joinPath(cwd, '..'), type: 'dir', size: 0, mtime: 0, mode: 0 }); } return list; }); @@ -144,7 +144,7 @@ export class PanelState { up(): void { 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. */ diff --git a/ui/src/app/platform.ts b/ui/src/app/platform.ts index 4a81697..2aa1e41 100644 --- a/ui/src/app/platform.ts +++ b/ui/src/app/platform.ts @@ -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). */ 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' = typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main'; diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index c9d43cd..b9ab007 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -3,7 +3,7 @@ import { NgIcon } from '@ng-icons/core'; import { BRANDING } from '@shared/branding'; import type { AccentTone } from '@shared/types'; 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 { SettingsService } from '../settings.service'; @@ -78,7 +78,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; @if (panels.workspaces().length > 1) {