[Init] Initial commit - NetMesh terminal manager
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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,395 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type * as Monaco from "monaco-editor";
import { activeTabStore, fromEditorTabId, isEditorTabId } from "./activeTabStore";
// POSIX-style normalization: collapse "/./" and duplicate slashes, not ".." (remote paths
// may contain semantic ".." segments we don't want to resolve client-side).
const normalizePath = (p: string): string => {
const collapsed = p.replace(/\/+/g, "/").replace(/\/\.(?=\/|$)/g, "");
return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
};
export type EditorTabId = string;
export type EditorSavingState = "idle" | "saving" | "error";
export interface EditorTab {
id: EditorTabId;
kind: "editor";
/** SFTP connection id (matches SftpConnection.id). Session lookup key. */
sessionId: string;
/** Stable SFTP pane tab id — survives browse reconnects that regenerate connection ids. */
sftpTabId: string;
/** Stable endpoint id; used to verify the session is still the one we opened against. */
hostId: string;
remotePath: string;
fileName: string;
languageId: string;
content: string;
baselineContent: string;
wordWrap: boolean;
viewState: Monaco.editor.ICodeEditorViewState | null;
savingState: EditorSavingState;
saveError: string | null;
}
type Listener = () => void;
let idCounter = 0;
const genId = (): EditorTabId => `edt_${Date.now().toString(36)}_${(++idCounter).toString(36)}`;
export class EditorTabStore {
private tabs: EditorTab[] = [];
private listeners = new Set<Listener>();
private presenceListeners = new Set<Listener>();
private pendingNotify = false;
private pendingPresenceNotify = false;
private presenceRevision = 0;
getTabs = (): readonly EditorTab[] => this.tabs;
getTab = (id: EditorTabId): EditorTab | undefined => this.tabs.find((t) => t.id === id);
hasTabForSessions = (sessionIds: ReadonlySet<string>): boolean =>
this.tabs.some((tab) => sessionIds.has(tab.sessionId));
hasTabForSftpTabIds = (sftpTabIds: ReadonlySet<string>): boolean =>
this.tabs.some((tab) => sftpTabIds.has(tab.sftpTabId));
/** Match promoted editors by stable pane tab id and/or live connection id. */
hasOwnedEditorForSftpOwner = (params: {
sessionIds: ReadonlySet<string>;
sftpTabIds: ReadonlySet<string>;
}): boolean =>
this.tabs.some((tab) =>
params.sftpTabIds.has(tab.sftpTabId) || params.sessionIds.has(tab.sessionId),
);
getPresenceRevision = (): number => this.presenceRevision;
/** Update editor tabs after browse reconnect replaces a connection id. */
remapSessionId = (fromSessionId: string, toSessionId: string): void => {
if (fromSessionId === toSessionId) return;
let changed = false;
this.tabs = this.tabs.map((tab) => {
if (tab.sessionId !== fromSessionId) return tab;
changed = true;
return { ...tab, sessionId: toSessionId };
});
if (changed) this.notifyStructural();
};
isDirty = (id: EditorTabId): boolean => {
const t = this.getTab(id);
return !!t && t.content !== t.baselineContent;
};
updateContent = (
id: EditorTabId,
content: string,
viewState: Monaco.editor.ICodeEditorViewState | null,
) => {
this.patch(id, { content, viewState });
};
markSaved = (id: EditorTabId, newBaseline: string) => {
this.patch(id, { baselineContent: newBaseline, savingState: "idle", saveError: null });
};
setWordWrap = (id: EditorTabId, value: boolean) => {
this.patch(id, { wordWrap: value });
};
setLanguage = (id: EditorTabId, languageId: string) => {
this.patch(id, { languageId });
};
setSavingState = (id: EditorTabId, state: EditorSavingState, error: string | null = null) => {
const patch: Partial<EditorTab> = { savingState: state };
if (state === "idle") patch.saveError = null;
else if (state === "error") patch.saveError = error;
this.patch(id, patch);
};
close = (id: EditorTabId) => {
const next = this.tabs.filter((t) => t.id !== id);
if (next.length !== this.tabs.length) {
this.tabs = next;
this.notifyStructural();
}
};
/**
* Force-close every tab bound to any of the given sessionIds, with no dirty
* prompt. Intended for cases where the owning SFTP instance has gone away
* entirely (e.g. the hosting terminal tab was closed) and there is no
* realistic save channel anyway. Returns the closed tab ids.
*/
private tabMatchesOwner = (
tab: EditorTab,
owner: { sessionId?: string; sftpTabId?: string },
): boolean =>
(owner.sessionId != null && tab.sessionId === owner.sessionId)
|| (owner.sftpTabId != null && tab.sftpTabId === owner.sftpTabId);
/**
* Force-close every tab bound to any owner id, with no dirty prompt.
* Matches by live connection id and/or stable SFTP pane tab id.
*/
forceCloseByOwners = (owners: {
sessionIds?: readonly string[];
sftpTabIds?: readonly string[];
}): EditorTabId[] => {
const sessionSet = new Set(owners.sessionIds ?? []);
const tabIdSet = new Set(owners.sftpTabIds ?? []);
if (sessionSet.size === 0 && tabIdSet.size === 0) return [];
const removed = this.tabs
.filter((t) => sessionSet.has(t.sessionId) || tabIdSet.has(t.sftpTabId))
.map((t) => t.id);
if (removed.length === 0) return [];
const removedSet = new Set(removed);
this.tabs = this.tabs.filter((t) => !removedSet.has(t.id));
this.notifyStructural();
const activeId = activeTabStore.getActiveTabId();
if (isEditorTabId(activeId)) {
const activeEditorId = fromEditorTabId(activeId);
if (activeEditorId && removed.includes(activeEditorId)) {
activeTabStore.setActiveTabId('vault');
}
}
return removed;
};
forceCloseBySessions = (sessionIds: readonly string[]): EditorTabId[] =>
this.forceCloseByOwners({ sessionIds });
promoteFromModal = (snapshot: {
sessionId: string;
sftpTabId: string;
hostId: string;
remotePath: string;
fileName: string;
languageId: string;
content: string;
baselineContent: string;
wordWrap: boolean;
viewState: Monaco.editor.ICodeEditorViewState | null;
}): EditorTabId => {
const normalized = normalizePath(snapshot.remotePath);
const existing = this.tabs.find(
(t) => t.sessionId === snapshot.sessionId && normalizePath(t.remotePath) === normalized,
);
if (existing) {
this.patch(existing.id, {
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
// keep languageId/hostId/fileName stable; they shouldn't change for the same path
});
return existing.id;
}
const tab: EditorTab = {
id: this.makeId(),
kind: "editor",
sessionId: snapshot.sessionId,
sftpTabId: snapshot.sftpTabId,
hostId: snapshot.hostId,
remotePath: snapshot.remotePath,
fileName: snapshot.fileName,
languageId: snapshot.languageId,
content: snapshot.content,
baselineContent: snapshot.baselineContent,
wordWrap: snapshot.wordWrap,
viewState: snapshot.viewState,
savingState: "idle",
saveError: null,
};
this.tabs = [...this.tabs, tab];
this.notifyStructural();
return tab.id;
};
/**
* Walk editor tabs owned by a connection id and/or SFTP pane tab id. Clean tabs
* close silently; dirty tabs prompt via `promptChoice`. 'save' invokes `saveTab`
* and closes only on its success. Any 'cancel' aborts the batch and returns false.
*/
confirmCloseByOwner = async (
owner: { sessionId?: string; sftpTabId?: string },
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> => {
const matching = this.tabs.filter((t) => this.tabMatchesOwner(t, owner));
for (const tab of matching) {
const dirty = tab.content !== tab.baselineContent;
if (!dirty) {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
const choice = await promptChoice(tab);
if (choice === "cancel") return false;
if (choice === "discard") {
onCloseTab?.(tab.id);
this.close(tab.id);
continue;
}
if (choice === "save") {
if (!saveTab) throw new Error("saveTab callback required when 'save' choice is possible");
try {
await saveTab(tab.id);
} catch {
// Save failed — treat like cancel (keep tab open, abort batch so the user sees the error)
return false;
}
onCloseTab?.(tab.id);
this.close(tab.id);
}
}
return true;
};
confirmCloseBySession = async (
sessionId: string,
promptChoice: (tab: EditorTab) => Promise<"save" | "discard" | "cancel">,
saveTab?: (tabId: EditorTabId) => Promise<void>,
onCloseTab?: (tabId: EditorTabId) => void,
): Promise<boolean> =>
this.confirmCloseByOwner({ sessionId }, promptChoice, saveTab, onCloseTab);
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
};
/** Tab open/close/session remap only — not editor content or save-state churn. */
subscribePresence = (listener: Listener): (() => void) => {
this.presenceListeners.add(listener);
return () => { this.presenceListeners.delete(listener); };
};
/** TEST-ONLY: seed a tab without going through promote/openOrFocus. */
_debugInsert = (tab: EditorTab) => {
this.tabs = [...this.tabs, tab];
this.notifyStructural();
};
protected makeId = genId;
protected patch = (id: EditorTabId, patch: Partial<EditorTab>) => {
let changed = false;
this.tabs = this.tabs.map((t) => {
if (t.id !== id) return t;
changed = true;
return { ...t, ...patch };
});
if (changed) this.notifyContent();
};
protected notifyContent = () => {
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach((l) => l());
});
};
protected notifyStructural = () => {
this.presenceRevision += 1;
this.notifyPresence();
this.notifyContent();
};
protected notifyPresence = () => {
if (this.pendingPresenceNotify) return;
this.pendingPresenceNotify = true;
Promise.resolve().then(() => {
this.pendingPresenceNotify = false;
this.presenceListeners.forEach((l) => l());
});
};
}
export const editorTabStore = new EditorTabStore();
// Hooks
const getTabsSnapshot = () => editorTabStore.getTabs();
export const useEditorTabs = (): readonly EditorTab[] =>
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot, getTabsSnapshot);
/**
* Chrome-only editor tab fields for App shell / TopTabs ordering.
* Content/save-state churn must not flow through this list.
*/
export type EditorTabChrome = Pick<
EditorTab,
| 'id'
| 'kind'
| 'sessionId'
| 'sftpTabId'
| 'hostId'
| 'remotePath'
| 'fileName'
| 'languageId'
>;
const projectEditorTabChrome = (tab: EditorTab): EditorTabChrome => ({
id: tab.id,
kind: tab.kind,
sessionId: tab.sessionId,
sftpTabId: tab.sftpTabId,
hostId: tab.hostId,
remotePath: tab.remotePath,
fileName: tab.fileName,
languageId: tab.languageId,
});
export const useHasEditorTabForSessions = (
getSessionIds: () => ReadonlySet<string>,
): boolean => {
const getSnapshot = useCallback(
() => editorTabStore.hasTabForSessions(getSessionIds()),
[getSessionIds],
);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot, getSnapshot);
};
/** Re-render only when editor tabs open/close or their SFTP session binding changes. */
export const useEditorTabPresenceRevision = (): number =>
useSyncExternalStore(
editorTabStore.subscribePresence,
() => editorTabStore.getPresenceRevision(),
() => editorTabStore.getPresenceRevision(),
);
/**
* Subscribe to open/close/remap only. Safe for App domain memos and tab strip
* structure; dirty dots and Monaco content must use per-tab hooks.
*/
export const useEditorTabChromeList = (): readonly EditorTabChrome[] => {
const revision = useEditorTabPresenceRevision();
return useMemo(() => {
void revision;
return editorTabStore.getTabs().map(projectEditorTabChrome);
}, [revision]);
};
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot, getSnapshot);
};
/**
* Per-tab dirty flag. Content edits notify the store, but React skips re-render
* when this tab's dirty boolean is unchanged (Object.is).
*/
export const useEditorTabDirty = (id: EditorTabId): boolean =>
useSyncExternalStore(
editorTabStore.subscribe,
() => editorTabStore.isDirty(id),
() => editorTabStore.isDirty(id),
);