46e3899945
- Changes (#72): a file git reports `MM` whose working copy equals HEAD again (a staged change since undone on disk) is no longer listed — JetBrains' GitChangesCollector drops it the same way. Mode-only changes stay listed. statusCounts (the dirty-tree safety checks) is unchanged. - Chat (#73): Claude Code runs no hook before a folder is trusted, so a new pane's session has no row; the chat now says the session has not started, offers Open terminal, and asks before typing a message or /model //effort into the pane (an Enter would pick the trust prompt's "No, exit"). - Terminal: Safari + macOS's own Vietnamese Telex replaces the whole word on each key (insertReplacementText), which xterm.js ignores — only each word's first letter reached the pane. The edit is now sent as DELs + the new tail. - Also: Ctrl+V pastes a screenshot into the terminal as a file path; the folder browser fallback lists files for a file field (`files=1`); the macOS native picker opens in front of the browser; a hidden tab is marked while a chat waits or once a turn ended; chat live line shows running calls, turn time and tokens, and "Done in …".
226 lines
9.0 KiB
JavaScript
226 lines
9.0 KiB
JavaScript
/**
|
|
* @file native-dialog.js
|
|
* @description Opens the operating system's own folder/file picker ON THE
|
|
* SERVER'S DESKTOP and returns the absolute path the user chose. A browser's
|
|
* native picker never hands a page an absolute path, and the dashboard is
|
|
* local-first, so the server asks the desktop itself: zenity or kdialog on
|
|
* Linux (with the graphical session's variables from `desktop-env.js`, which a
|
|
* systemd user service lacks), `osascript` on macOS, PowerShell's WinForms
|
|
* dialogs on Windows. Runs with `execFile` (never a shell, never sync — a
|
|
* dialog waits on a human for minutes), one dialog at a time. Nothing from the
|
|
* request reaches argv except the starting folder, resolved to an absolute
|
|
* path first. macOS and Windows are code-only, untested on those systems.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { execFile } = require("node:child_process");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { desktopEnv } = require("./desktop-env");
|
|
|
|
const TITLES = { dir: "Select a folder", file: "Select a file" };
|
|
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
|
const defaultExec = (cmd, args, env, signal) =>
|
|
new Promise((resolve) => {
|
|
execFile(
|
|
cmd,
|
|
args,
|
|
{ env, timeout: TIMEOUT_MS, windowsHide: true, encoding: "utf8", signal },
|
|
(err, stdout, stderr) =>
|
|
resolve({
|
|
missing: err?.code === "ENOENT",
|
|
code: err ? (typeof err.code === "number" ? err.code : -1) : 0,
|
|
stdout: String(stdout || ""),
|
|
stderr: String(stderr || ""),
|
|
})
|
|
);
|
|
});
|
|
|
|
let execImpl = defaultExec;
|
|
|
|
/** Test seam — same pattern as desktop-env.js; `null` restores the real one. */
|
|
function __setExecImpl(fn) {
|
|
execImpl = fn || defaultExec;
|
|
}
|
|
|
|
/** An existing folder to open the dialog in: `start` (or its parent when it is a file), else home. */
|
|
function startDir(start) {
|
|
if (typeof start === "string" && start.trim()) {
|
|
const raw = start.trim().replace(/^~(?=$|[\\/])/, os.homedir());
|
|
const abs = path.resolve(raw);
|
|
for (const p of [abs, path.dirname(abs)]) {
|
|
try {
|
|
if (fs.statSync(p).isDirectory()) return p;
|
|
} catch {
|
|
// missing — try the parent, then home
|
|
}
|
|
}
|
|
}
|
|
return os.homedir();
|
|
}
|
|
|
|
// user32 calls PowerShell has no cmdlet for: `Front` makes a window the
|
|
// foreground one from a background process (see dialogCommands).
|
|
const WIN_FRONT = [
|
|
'[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);',
|
|
'[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);',
|
|
'[DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr h);',
|
|
'[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();',
|
|
'[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, IntPtr p);',
|
|
'[DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool on);',
|
|
'[DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();',
|
|
"public static void Front(IntPtr h) {",
|
|
" uint fg = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);",
|
|
" uint me = GetCurrentThreadId();",
|
|
" bool joined = fg != 0 && fg != me && AttachThreadInput(me, fg, true);",
|
|
" BringWindowToTop(h); SetForegroundWindow(h);",
|
|
" if (joined) AttachThreadInput(me, fg, false);",
|
|
"}",
|
|
].join("\n");
|
|
|
|
/** The candidate dialog commands for a platform, in order of preference. */
|
|
function dialogCommands(kind, dir, platform = process.platform) {
|
|
const title = TITLES[kind];
|
|
if (platform === "darwin") {
|
|
const what = kind === "dir" ? "folder" : "file";
|
|
// osascript started by a background server is not the frontmost app, so
|
|
// its dialog opened BEHIND the browser; `activate` brings it forward.
|
|
return [
|
|
[
|
|
"osascript",
|
|
[
|
|
"-e",
|
|
"on run argv",
|
|
"-e",
|
|
"activate",
|
|
"-e",
|
|
`POSIX path of (choose ${what} with prompt (item 1 of argv) default location (POSIX file (item 2 of argv)))`,
|
|
"-e",
|
|
"end run",
|
|
title,
|
|
dir,
|
|
],
|
|
],
|
|
];
|
|
}
|
|
if (platform === "win32") {
|
|
const dialog =
|
|
kind === "dir"
|
|
? "$d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.SelectedPath = $env:AGENTHUB_PICK_START; $d.Description = $env:AGENTHUB_PICK_TITLE"
|
|
: "$d = New-Object System.Windows.Forms.OpenFileDialog; $d.InitialDirectory = $env:AGENTHUB_PICK_START; $d.Title = $env:AGENTHUB_PICK_TITLE";
|
|
const result = kind === "dir" ? "$d.SelectedPath" : "$d.FileName";
|
|
// The server is a background process, and Windows keeps one from bringing
|
|
// a window forward: an ownerless dialog opened at the BOTTOM of the
|
|
// z-order, behind the browser. So the dialog gets a TopMost owner, made
|
|
// the foreground window by joining the current foreground thread's input
|
|
// (AttachThreadInput — the lock exempts it). The first ShowWindow of a
|
|
// process started with windowsHide is turned into a hide; a throwaway call
|
|
// spends it, or the owner stays hidden and takes the dialog down with it.
|
|
const script = [
|
|
"$ProgressPreference = 'SilentlyContinue'",
|
|
"[Console]::OutputEncoding = [Text.Encoding]::UTF8",
|
|
"Add-Type -AssemblyName System.Windows.Forms",
|
|
`Add-Type -Namespace AgentHub -Name Win -MemberDefinition '${WIN_FRONT}'`,
|
|
"$o = New-Object System.Windows.Forms.Form",
|
|
"$o.TopMost = $true; $o.ShowInTaskbar = $false; $o.FormBorderStyle = 'None'; $o.Opacity = 0; $o.Width = 1; $o.Height = 1; $o.StartPosition = 'CenterScreen'",
|
|
"[void][AgentHub.Win]::ShowWindow($o.Handle, 1)",
|
|
"$o.Show()",
|
|
"[AgentHub.Win]::Front($o.Handle)",
|
|
dialog,
|
|
`if ($d.ShowDialog($o) -eq 'OK') { [Console]::Out.Write(${result}) } else { exit 1 }`,
|
|
].join("\n");
|
|
// -EncodedCommand: the C# above carries double quotes, which Windows
|
|
// PowerShell's own command-line parsing mangles inside -Command.
|
|
return [
|
|
[
|
|
"powershell.exe",
|
|
[
|
|
"-NoProfile",
|
|
"-STA",
|
|
"-EncodedCommand",
|
|
Buffer.from(script, "utf16le").toString("base64"),
|
|
],
|
|
],
|
|
];
|
|
}
|
|
const sep = dir.endsWith("/") ? "" : "/";
|
|
return [
|
|
[
|
|
"zenity",
|
|
[
|
|
"--file-selection",
|
|
`--title=${title}`,
|
|
`--filename=${dir}${sep}`,
|
|
...(kind === "dir" ? ["--directory"] : []),
|
|
],
|
|
],
|
|
[
|
|
"kdialog",
|
|
[kind === "dir" ? "--getexistingdirectory" : "--getopenfilename", dir, "--title", title],
|
|
],
|
|
];
|
|
}
|
|
|
|
// A dialog that could not reach the desktop exits 1 too — a stale DISPLAY
|
|
// handed over by `systemctl --user show-environment` after a logout, say.
|
|
const NO_DISPLAY_RE =
|
|
/cannot open display|could not connect to display|failed to open display|cannot connect to x server|unable to init server/i;
|
|
|
|
/**
|
|
* Whether an exit status of 1 means the user pressed Cancel. zenity/kdialog
|
|
* may print GTK/Qt warnings on a normal Cancel, so only a display failure
|
|
* counts against it; osascript says Cancel as error -128 and nothing else.
|
|
*/
|
|
function isCancel(cmd, stderr = "") {
|
|
if (cmd === "osascript") return /-128/.test(stderr);
|
|
if (cmd === "powershell.exe") return !stderr.trim();
|
|
return !NO_DISPLAY_RE.test(stderr);
|
|
}
|
|
|
|
function fail(code, message) {
|
|
return Object.assign(new Error(message), { code });
|
|
}
|
|
|
|
let busy = false;
|
|
|
|
/**
|
|
* Show the native picker and wait for the user.
|
|
* @param {"dir"|"file"} kind
|
|
* @param {string} [start] Folder (or file) to open the dialog at.
|
|
* @param {AbortSignal} [signal] Closes the dialog — the browser went away.
|
|
* @returns {Promise<string|null>} The chosen absolute path, or null when cancelled.
|
|
* @throws `ENODIALOG` (no desktop or no dialog program), `EBUSY` (one is already open), `EDIALOG`.
|
|
*/
|
|
async function pickPath(kind, start, signal) {
|
|
if (busy) throw fail("EBUSY", "a picker is already open on the desktop");
|
|
const env = { ...process.env, ...desktopEnv() };
|
|
if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
|
|
throw fail("ENODIALOG", "no graphical desktop session to show a picker on");
|
|
}
|
|
const dir = startDir(start);
|
|
env.AGENTHUB_PICK_START = dir;
|
|
env.AGENTHUB_PICK_TITLE = TITLES[kind];
|
|
busy = true;
|
|
try {
|
|
for (const [cmd, args] of dialogCommands(kind, dir)) {
|
|
const r = await execImpl(cmd, args, env, signal);
|
|
if (r.missing) continue;
|
|
if (r.code === 1) {
|
|
if (isCancel(cmd, r.stderr)) return null; // every one of these exits 1 on Cancel
|
|
throw fail("ENODIALOG", `${cmd} could not open a picker on the desktop`);
|
|
}
|
|
const out = r.stdout.replace(/[\r\n]+$/, "");
|
|
if (r.code !== 0 || !out) throw fail("EDIALOG", `${cmd} exited with ${r.code}`);
|
|
// osascript ends a folder with "/"; "/" itself stays.
|
|
return out.replace(/\/+$/, "") || "/";
|
|
}
|
|
throw fail("ENODIALOG", "no native picker found (install zenity or kdialog)");
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
|
|
module.exports = { pickPath, dialogCommands, startDir, isCancel, __setExecImpl };
|