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>
144 lines
4.0 KiB
TypeScript
144 lines
4.0 KiB
TypeScript
import { createReadStream, createWriteStream } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import type { Stats } from "node:fs";
|
|
import type { EntryType, FileEntry } from "../../shared/types";
|
|
import type { FileSystemProvider } from "./provider";
|
|
|
|
function typeOf(st: Stats): EntryType {
|
|
if (st.isSymbolicLink()) return "symlink";
|
|
return st.isDirectory() ? "dir" : "file";
|
|
}
|
|
|
|
/**
|
|
* uid/gid -> names, cached per process (a listing usually has 1-3 owners).
|
|
*
|
|
* PORTABILITY: POSIX-only lookups (`id -nu` works on macOS and Linux; group
|
|
* names use dscacheutil on macOS, getent elsewhere). On Windows these return
|
|
* undefined and the Owner/Group columns show "—".
|
|
*/
|
|
const idNameCache = new Map<string, string | undefined>();
|
|
|
|
function resolveId(kind: "user" | "group", id: number): string | undefined {
|
|
if (process.platform === "win32") return undefined;
|
|
const cacheKey = `${kind}:${id}`;
|
|
if (idNameCache.has(cacheKey)) return idNameCache.get(cacheKey);
|
|
let name: string | undefined;
|
|
try {
|
|
const cmd =
|
|
kind === "user"
|
|
? ["id", "-nu", String(id)]
|
|
: process.platform === "darwin"
|
|
? ["dscacheutil", "-q", "group", "-a", "gid", String(id)]
|
|
: ["getent", "group", String(id)];
|
|
const proc = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "ignore" });
|
|
if (proc.success) {
|
|
const out = proc.stdout.toString();
|
|
if (kind === "user") name = out.trim() || undefined;
|
|
else if (process.platform === "darwin") name = /^name:\s*(.+)$/m.exec(out)?.[1]?.trim();
|
|
else name = out.split(":")[0]?.trim() || undefined;
|
|
}
|
|
} catch {
|
|
// lookup tool missing — fall through to the numeric id
|
|
}
|
|
name ??= String(id);
|
|
idNameCache.set(cacheKey, 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 {
|
|
return {
|
|
name,
|
|
path: full,
|
|
type: typeOf(st),
|
|
size: st.size,
|
|
mtime: st.mtimeMs,
|
|
mode: st.mode & 0o7777,
|
|
owner: resolveId("user", st.uid),
|
|
group: resolveId("group", st.gid),
|
|
};
|
|
}
|
|
|
|
export class LocalProvider implements FileSystemProvider {
|
|
readonly kind = "local" as const;
|
|
readonly fsKey = "local";
|
|
|
|
join = (...parts: string[]) => path.join(...parts);
|
|
dirname = (p: string) => path.dirname(p);
|
|
basename = (p: string) => path.basename(p);
|
|
normalize = (p: string) => path.resolve(p);
|
|
|
|
async homeDir(): Promise<string> {
|
|
return os.homedir();
|
|
}
|
|
|
|
async list(dir: string): Promise<FileEntry[]> {
|
|
const names = await fs.readdir(dir);
|
|
const entries: FileEntry[] = [];
|
|
for (const name of names) {
|
|
const full = path.join(dir, name);
|
|
try {
|
|
entries.push(toEntry(name, full, await fs.lstat(full)));
|
|
} catch {
|
|
// entry vanished or is unreadable — skip it
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
async stat(p: string): Promise<FileEntry> {
|
|
return toEntry(path.basename(p), p, await fs.lstat(p));
|
|
}
|
|
|
|
async mkdir(p: string): Promise<void> {
|
|
await fs.mkdir(p);
|
|
}
|
|
|
|
async createEmptyFile(p: string): Promise<void> {
|
|
await fs.writeFile(p, "", { flag: "wx" });
|
|
}
|
|
|
|
async rename(oldPath: string, newPath: string): Promise<void> {
|
|
await fs.rename(oldPath, newPath);
|
|
}
|
|
|
|
async deleteFile(p: string): Promise<void> {
|
|
await fs.unlink(p);
|
|
}
|
|
|
|
async deleteEmptyDir(p: string): Promise<void> {
|
|
await fs.rmdir(p);
|
|
}
|
|
|
|
async chmod(p: string, mode: number): Promise<void> {
|
|
await fs.chmod(p, mode);
|
|
}
|
|
|
|
createReadStream(p: string) {
|
|
return createReadStream(p);
|
|
}
|
|
|
|
createWriteStream(p: string) {
|
|
return createWriteStream(p);
|
|
}
|
|
|
|
async dispose(): Promise<void> {}
|
|
}
|