Files
NetMesh/components/terminal/autocomplete/localShellHistorySeed.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

164 lines
6.0 KiB
TypeScript

/**
* Seed autocomplete command history from the local machine's shell histfiles.
*
* Local Terminal sessions previously used a per-session hostId (`local-${sessionId}`),
* so Netcatty's autocomplete history never accumulated across opens. Even with a
* stable hostId, a fresh install / new machine has an empty store until the user
* types commands inside Netcatty — while Ghostty (and similar terminals) surface
* suggestions from ~/.zsh_history / ~/.bash_history immediately.
*
* This module imports those histfiles once per hostId into commandHistoryStore
* so prefix autocomplete can match them.
*/
import {
isNetcattyAiHistoryCommand,
isNetcattyManagedStartupHistoryCommand,
mergeRemoteHistory,
parseBashHistory,
parseFishHistory,
parseZshHistory,
} from "../../../domain/remoteHistory";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { flushCommandHistoryStore, recordCommand } from "./commandHistoryStore";
const SEED_FLAG_PREFIX = "netcatty:localHistSeeded:";
const MAX_SEED_COMMANDS = 500;
/** Cap histfile reads so a multi-MB history does not stall Local Terminal mount. */
const MAX_HISTFILE_BYTES = 512 * 1024;
type LocalFsBridge = {
getHomeDir?: () => Promise<string>;
readLocalFile?: (
path: string,
options?: { maxBytes?: number },
) => Promise<ArrayBuffer | Buffer | Uint8Array | string>;
};
const inFlightSeeds = new Map<string, Promise<number>>();
function getBridge(): LocalFsBridge | undefined {
return (window as Window & { netcatty?: LocalFsBridge }).netcatty;
}
function joinHomePath(home: string, relativeUnix: string): string {
const normalizedHome = home.replace(/[/\\]+$/, "");
const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
const relative = sep === "\\" ? relativeUnix.replace(/\//g, "\\") : relativeUnix;
return `${normalizedHome}${sep}${relative}`;
}
function decodeHistfileBytes(bytes: Uint8Array): string {
// Main-process reads already return at most MAX_HISTFILE_BYTES. A buffer that
// fills the budget is treated as a truncated tail, so drop the first
// (possibly partial) line before parsing.
let text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
if (bytes.byteLength >= MAX_HISTFILE_BYTES) {
const firstNewline = text.indexOf("\n");
if (firstNewline >= 0) text = text.slice(firstNewline + 1);
}
return text;
}
async function readTextFile(bridge: LocalFsBridge, path: string): Promise<string | null> {
if (!bridge.readLocalFile) return null;
try {
// Ask the main process to return only the trailing bytes so multi-MB
// histfiles never cross the IPC boundary in full.
const raw = await bridge.readLocalFile(path, { maxBytes: MAX_HISTFILE_BYTES });
if (typeof raw === "string") {
// Bridge returned a string (tests / alternate adapters). Cap by UTF-8
// byte length so this path matches the binary branch.
const encoded = new TextEncoder().encode(raw);
return decodeHistfileBytes(encoded);
}
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
return decodeHistfileBytes(bytes);
} catch {
return null;
}
}
function alreadySeeded(hostId: string): boolean {
return localStorageAdapter.readBoolean(`${SEED_FLAG_PREFIX}${hostId}`) === true;
}
function markSeeded(hostId: string): void {
localStorageAdapter.writeBoolean(`${SEED_FLAG_PREFIX}${hostId}`, true);
}
async function seedLocalShellHistoryFromHistfilesOnce(
hostId: string,
os: "linux" | "windows" | "macos",
): Promise<number> {
if (!hostId || alreadySeeded(hostId)) return 0;
const bridge = getBridge();
if (!bridge?.getHomeDir || !bridge.readLocalFile) return 0;
let home: string;
try {
home = await bridge.getHomeDir();
} catch {
return 0;
}
if (!home) return 0;
const [zshText, bashText, fishText, fishAltText] = await Promise.all([
readTextFile(bridge, joinHomePath(home, ".zsh_history")),
readTextFile(bridge, joinHomePath(home, ".bash_history")),
readTextFile(bridge, joinHomePath(home, ".local/share/fish/fish_history")),
readTextFile(bridge, joinHomePath(home, ".config/fish/fish_history")),
]);
const lists = [
zshText ? parseZshHistory(zshText) : [],
bashText ? parseBashHistory(bashText) : [],
fishText ? parseFishHistory(fishText) : [],
!fishText && fishAltText ? parseFishHistory(fishAltText) : [],
];
const merged = mergeRemoteHistory(lists, MAX_SEED_COMMANDS);
let recorded = 0;
// mergeRemoteHistory returns newest-first; record oldest-first so frequency /
// lastUsedAt ordering stays sensible if the same command appears later.
for (const entry of [...merged].reverse()) {
const command = entry.command.trim();
if (!command) continue;
if (isNetcattyAiHistoryCommand(command)) continue;
if (isNetcattyManagedStartupHistoryCommand(command)) continue;
recordCommand(command, hostId, os);
recorded += 1;
}
// Only persist the seeded flag after we actually imported commands and
// flushed the store. An empty/missing histfile must remain retryable so a
// later Local Terminal open can pick up history once it exists (#2037).
if (recorded > 0 && flushCommandHistoryStore()) {
markSeeded(hostId);
}
return recorded;
}
/**
* Import local shell histfiles into the autocomplete history store for `hostId`.
* Returns the number of commands newly recorded. No-ops when already seeded for
* this hostId, when the local FS bridge is unavailable, or when histfiles are
* empty/missing (those cases stay retryable on the next Local Terminal open).
*/
export async function seedLocalShellHistoryFromHistfiles(
hostId: string,
os: "linux" | "windows" | "macos" = "macos",
): Promise<number> {
if (!hostId || alreadySeeded(hostId)) return 0;
const existing = inFlightSeeds.get(hostId);
if (existing) return existing;
const pending = seedLocalShellHistoryFromHistfilesOnce(hostId, os).finally(() => {
inFlightSeeds.delete(hostId);
});
inFlightSeeds.set(hostId, pending);
return pending;
}