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:
+20
-5
@@ -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" });
|
||||
|
||||
+17
-6
@@ -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" });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+15
-3
@@ -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() })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-8
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
/** Main window only: open (or focus) a native editor window. */
|
||||
openEditor: { params: EditorOpenContext; response: Result };
|
||||
|
||||
Reference in New Issue
Block a user