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>
214 lines
6.8 KiB
TypeScript
214 lines
6.8 KiB
TypeScript
import { watch, type FSWatcher } from "node:fs";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pipeline } from "node:stream/promises";
|
|
import type { FileSystemProvider } from "./fs/provider";
|
|
import type { SessionRegistry } from "./fs/registry";
|
|
import type { SettingsStore } from "./store/settings";
|
|
import { appDataDir } from "./store/paths";
|
|
import type { TransferEngine } from "./transfers/engine";
|
|
|
|
// Built-in editor guard: bigger than this is not a config file.
|
|
const MAX_EDIT_BYTES = 2 * 1024 * 1024;
|
|
const SAVE_DEBOUNCE_MS = 400;
|
|
|
|
/** Read a whole file as UTF-8 through any provider (local or sftp). */
|
|
export async function readFileText(
|
|
provider: FileSystemProvider,
|
|
filePath: string,
|
|
): Promise<string> {
|
|
const stat = await provider.stat(filePath);
|
|
if (stat.size > MAX_EDIT_BYTES) {
|
|
throw new Error(
|
|
`File is ${Math.round(stat.size / 1024 / 1024)}MB — too large for the built-in editor. Use a custom editor instead.`,
|
|
);
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
const stream = provider.createReadStream(filePath);
|
|
for await (const chunk of stream) {
|
|
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
}
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
|
|
/** Write UTF-8 content through any provider. */
|
|
export function writeFileText(
|
|
provider: FileSystemProvider,
|
|
filePath: string,
|
|
content: string,
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const stream = provider.createWriteStream(filePath);
|
|
stream.on("error", reject);
|
|
stream.end(Buffer.from(content, "utf8"), () => resolve());
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 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 {
|
|
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" });
|
|
}
|
|
|
|
interface WatchedEdit {
|
|
sessionId: string;
|
|
remotePath: string;
|
|
tempDir: string;
|
|
tempFile: string;
|
|
watcher: FSWatcher;
|
|
debounce: ReturnType<typeof setTimeout> | null;
|
|
}
|
|
|
|
/**
|
|
* External-editor flow for remote files: download to a temp dir, open the
|
|
* editor, watch the directory (survives atomic rename-saves), and enqueue an
|
|
* upload job on every save. Watchers die with their session; temp dirs left
|
|
* behind by a crash are offered for cleanup at next boot.
|
|
*/
|
|
export class EditManager {
|
|
private readonly editsDir = path.join(appDataDir(), "edits");
|
|
private watched: WatchedEdit[] = [];
|
|
|
|
constructor(
|
|
private registry: SessionRegistry,
|
|
private engine: TransferEngine,
|
|
private settings: SettingsStore,
|
|
) {}
|
|
|
|
async editExternal(sessionId: string, filePath: string): Promise<{ watching: boolean }> {
|
|
const { editorApp } = await this.settings.load();
|
|
|
|
if (sessionId === "local") {
|
|
launchEditor(filePath, editorApp);
|
|
return { watching: false };
|
|
}
|
|
|
|
const provider = this.registry.get(sessionId);
|
|
const tempDir = path.join(this.editsDir, crypto.randomUUID().slice(0, 8));
|
|
const name = provider.basename(filePath);
|
|
const tempFile = path.join(tempDir, name);
|
|
|
|
await fs.mkdir(tempDir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(tempDir, "meta.json"),
|
|
JSON.stringify({ remotePath: filePath, sessionId, downloadedAt: new Date().toISOString() }),
|
|
);
|
|
const { createWriteStream } = await import("node:fs");
|
|
await pipeline(provider.createReadStream(filePath), createWriteStream(tempFile));
|
|
|
|
launchEditor(tempFile, editorApp);
|
|
|
|
const edit: WatchedEdit = {
|
|
sessionId,
|
|
remotePath: filePath,
|
|
tempDir,
|
|
tempFile,
|
|
watcher: watch(tempDir, (_event, changed) => {
|
|
if (changed !== name) return;
|
|
if (edit.debounce) clearTimeout(edit.debounce);
|
|
edit.debounce = setTimeout(() => this.uploadEdit(edit), SAVE_DEBOUNCE_MS);
|
|
}),
|
|
debounce: null,
|
|
};
|
|
this.watched.push(edit);
|
|
console.log(`[edit] watching ${tempFile} -> ${sessionId}:${filePath}`);
|
|
return { watching: true };
|
|
}
|
|
|
|
private uploadEdit(edit: WatchedEdit): void {
|
|
const provider = this.registry.get(edit.sessionId);
|
|
this.engine.enqueue({
|
|
srcSessionId: "local",
|
|
items: [{ path: edit.tempFile, type: "file" }],
|
|
dstSessionId: edit.sessionId,
|
|
dstDir: provider.dirname(edit.remotePath),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Park unsaveable editor content (window closed, session dead) in the
|
|
* leftovers dir so the next-boot prompt offers it back to the user.
|
|
*/
|
|
async rescueDraft(name: string, content: string): Promise<void> {
|
|
const dir = path.join(this.editsDir, crypto.randomUUID().slice(0, 8));
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(dir, "meta.json"),
|
|
JSON.stringify({ name, rescuedAt: new Date().toISOString() }),
|
|
);
|
|
await fs.writeFile(path.join(dir, name), content, "utf8");
|
|
console.log(`[edit] rescued unsaved draft of ${name} -> ${dir}`);
|
|
}
|
|
|
|
/** Session gone (disconnect, tab close, death): stop its watchers, drop temps. */
|
|
stopForSession(sessionId: string): void {
|
|
const [mine, rest] = [
|
|
this.watched.filter((w) => w.sessionId === sessionId),
|
|
this.watched.filter((w) => w.sessionId !== sessionId),
|
|
];
|
|
this.watched = rest;
|
|
for (const edit of mine) {
|
|
edit.watcher.close();
|
|
if (edit.debounce) clearTimeout(edit.debounce);
|
|
void fs.rm(edit.tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
/** Temp files from previous runs (crash / quit mid-edit). */
|
|
async listLeftovers(): Promise<string[]> {
|
|
const names: string[] = [];
|
|
let entries: string[];
|
|
try {
|
|
entries = await fs.readdir(this.editsDir);
|
|
} catch {
|
|
return names;
|
|
}
|
|
const active = new Set(this.watched.map((w) => path.basename(w.tempDir)));
|
|
for (const entry of entries) {
|
|
if (active.has(entry)) continue;
|
|
try {
|
|
const files = await fs.readdir(path.join(this.editsDir, entry));
|
|
const file = files.find((f) => f !== "meta.json");
|
|
names.push(file ?? entry);
|
|
} catch {
|
|
names.push(entry);
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
async clearLeftovers(): Promise<void> {
|
|
const active = new Set(this.watched.map((w) => path.basename(w.tempDir)));
|
|
let entries: string[] = [];
|
|
try {
|
|
entries = await fs.readdir(this.editsDir);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (active.has(entry)) continue;
|
|
await fs.rm(path.join(this.editsDir, entry), { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|