mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 14:03:01 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e76b33e32 |
No files matched your search
@@ -66,6 +66,8 @@ import {
|
||||
getSessionConnectionLabel,
|
||||
} from '../../domain/sessionTabTitle';
|
||||
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
|
||||
import { syncSessionHostLabels } from '../../domain/syncSessionHostLabels';
|
||||
import { useVaultSnapshotField } from './vaultSnapshotStore';
|
||||
|
||||
export function addWorkspaceIfMissing(
|
||||
workspaces: Workspace[],
|
||||
@@ -267,6 +269,20 @@ export const useSessionState = ({
|
||||
}
|
||||
}, [initialRestoreState.activeTabId]);
|
||||
|
||||
// Keep open sessions' connection label in sync with the vault. Sessions
|
||||
// snapshot `hostLabel` at connect time, so renaming a host would otherwise
|
||||
// leave its already-open tabs (and merged-workspace titles derived from
|
||||
// those labels) showing the old name until the session is closed. The
|
||||
// generated-title sync effect below re-runs on this session change and
|
||||
// refreshes synthesized workspace titles too.
|
||||
const vaultHosts = useVaultSnapshotField('hosts');
|
||||
useEffect(() => {
|
||||
if (vaultHosts.length === 0) return;
|
||||
// The helper returns the same array reference when nothing changed, so
|
||||
// React bails out of the state update.
|
||||
setSessions(prev => syncSessionHostLabels(prev, vaultHosts));
|
||||
}, [vaultHosts]);
|
||||
|
||||
// Keep generated merged-workspace titles (e.g. "01/02" after dragging tab
|
||||
// "02" onto "01") synchronized with the workspace's current membership and
|
||||
// each pane's connection label. `autoTitle: false` makes
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import type { Host } from "../../domain/models";
|
||||
import { EMPTY_VAULT_SNAPSHOT, publishVaultSnapshot } from "./vaultSnapshotStore";
|
||||
import { useSessionState } from "./useSessionState.ts";
|
||||
|
||||
type SessionState = ReturnType<typeof useSessionState>;
|
||||
|
||||
const testHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "web-01",
|
||||
hostname: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
os: "linux",
|
||||
tags: [],
|
||||
...overrides,
|
||||
} as Host);
|
||||
|
||||
test("rewriting the host label updates already-open session tabs on that host", async (t) => {
|
||||
publishVaultSnapshot(EMPTY_VAULT_SNAPSHOT);
|
||||
t.after(() => publishVaultSnapshot(EMPTY_VAULT_SNAPSHOT));
|
||||
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
|
||||
Object.assign(eventTarget, { setTimeout, clearTimeout });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: eventTarget,
|
||||
});
|
||||
t.after(() => {
|
||||
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
|
||||
const actEnvironment = globalThis as typeof globalThis & {
|
||||
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
||||
};
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
t.after(() => {
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
});
|
||||
|
||||
let state: SessionState | null = null;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const current = (): SessionState => {
|
||||
assert.ok(state);
|
||||
return state;
|
||||
};
|
||||
function Probe() {
|
||||
state = useSessionState({ persistSessionRestore: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
|
||||
let sessionId = "";
|
||||
await act(async () => {
|
||||
sessionId = current().connectToHost(testHost());
|
||||
});
|
||||
const sessionOf = () => current().sessions.find((s) => s.id === sessionId);
|
||||
assert.ok(sessionOf());
|
||||
assert.equal(sessionOf()?.hostLabel, "web-01");
|
||||
|
||||
// The host is renamed in the vault while the session stays open.
|
||||
await act(async () => {
|
||||
publishVaultSnapshot({
|
||||
...EMPTY_VAULT_SNAPSHOT,
|
||||
hosts: [testHost({ label: "web-01-renamed" })],
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(sessionOf()?.hostLabel, "web-01-renamed");
|
||||
|
||||
// Renaming the pane afterwards hands the title to the user rename.
|
||||
await act(async () => {
|
||||
current().submitSessionRename(sessionId, "my pane");
|
||||
});
|
||||
const renamed = sessionOf();
|
||||
assert.ok(renamed);
|
||||
assert.equal(renamed!.customName, "my pane");
|
||||
|
||||
// A later vault rename must not clobber the user-authored pane name.
|
||||
await act(async () => {
|
||||
publishVaultSnapshot({
|
||||
...EMPTY_VAULT_SNAPSHOT,
|
||||
hosts: [testHost({ label: "web-01-again" })],
|
||||
});
|
||||
});
|
||||
const after = sessionOf();
|
||||
assert.ok(after);
|
||||
assert.equal(after!.customName, "my pane");
|
||||
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { TerminalSession } from "./models";
|
||||
import { syncSessionHostLabels } from "./syncSessionHostLabels";
|
||||
|
||||
const session = (overrides: Partial<TerminalSession>): TerminalSession => ({
|
||||
id: "s1",
|
||||
hostId: "h1",
|
||||
hostLabel: "old-label",
|
||||
hostname: "10.0.0.1",
|
||||
username: "root",
|
||||
status: "connected",
|
||||
...overrides,
|
||||
} as TerminalSession);
|
||||
|
||||
test("syncSessionHostLabels mirrors the vault host label onto open sessions", () => {
|
||||
const sessions = [session({ id: "s1", hostId: "h1", hostLabel: "old" })];
|
||||
const next = syncSessionHostLabels(sessions, [{ id: "h1", label: "web-01" }]);
|
||||
assert.equal(next[0].hostLabel, "web-01");
|
||||
assert.notEqual(next, sessions);
|
||||
});
|
||||
|
||||
test("syncSessionHostLabels returns the same array when labels already match", () => {
|
||||
const sessions = [session({ hostLabel: "web-01" })];
|
||||
assert.equal(syncSessionHostLabels(sessions, [{ id: "h1", label: "web-01" }]), sessions);
|
||||
});
|
||||
|
||||
test("syncSessionHostLabels leaves sessions without a matching vault host untouched", () => {
|
||||
const sessions = [
|
||||
session({ id: "local", hostId: "local-terminal", hostLabel: "Local Terminal" }),
|
||||
session({ id: "serial", hostId: "serial-abc", hostLabel: "Serial: ttyUSB0" }),
|
||||
session({ id: "eph", hostId: "ephemeral-host", hostLabel: "Quick Connect" }),
|
||||
];
|
||||
const next = syncSessionHostLabels(sessions, [{ id: "h9", label: "other" }]);
|
||||
assert.equal(next, sessions);
|
||||
});
|
||||
|
||||
test("syncSessionHostLabels keeps a renamed pane's custom name authoritative", () => {
|
||||
const sessions = [session({ customName: "my pane", hostLabel: "old" })];
|
||||
const next = syncSessionHostLabels(sessions, [{ id: "h1", label: "web-01" }]);
|
||||
assert.equal(next, sessions);
|
||||
});
|
||||
|
||||
test("syncSessionHostLabels updates only hosts present in the vault", () => {
|
||||
const sessions = [
|
||||
session({ id: "s1", hostId: "h1", hostLabel: "old-a" }),
|
||||
session({ id: "s2", hostId: "h2", hostLabel: "old-b" }),
|
||||
];
|
||||
const next = syncSessionHostLabels(sessions, [{ id: "h1", label: "new-a" }]);
|
||||
assert.equal(next[0].hostLabel, "new-a");
|
||||
assert.equal(next[1].hostLabel, "old-b");
|
||||
});
|
||||
|
||||
test("syncSessionHostLabels preserves unknown labels for hosts with empty labels", () => {
|
||||
const sessions = [session({ hostId: "h1", hostLabel: "Serial: ttyS0" })];
|
||||
const next = syncSessionHostLabels(sessions, [{ id: "h1", label: "" }]);
|
||||
assert.equal(next, sessions);
|
||||
});
|
||||
|
||||
test("syncSessionHostLabels is a no-op without hosts", () => {
|
||||
const sessions = [session({})];
|
||||
assert.equal(syncSessionHostLabels(sessions, []), sessions);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Host, TerminalSession } from './models';
|
||||
|
||||
/**
|
||||
* Mirror vault host labels onto open terminal sessions.
|
||||
*
|
||||
* Session records snapshot the host label at connect time (`hostLabel`), so a
|
||||
* host renamed in the vault leaves already-open session tabs showing the old
|
||||
* name until the session is closed and reopened. Pure helper: returns the same
|
||||
* array reference when nothing needs to change, so React state updaters bail
|
||||
* out without triggering a re-render.
|
||||
*
|
||||
* Rules per session:
|
||||
* - Only sessions whose `hostId` matches a vault host are touched. Local
|
||||
* terminals and standalone serial-port sessions use a synthetic `hostId`,
|
||||
* and ephemeral/deep-link hosts are absent from the vault, so their snapshot
|
||||
* label (e.g. the shell name, "Serial: …") is preserved.
|
||||
* - A user rename of the pane (`customName`) wins over the host label in every
|
||||
* consumer, so renamed panes are left untouched; their `hostLabel` is only
|
||||
* an echo of the rename.
|
||||
*/
|
||||
export const syncSessionHostLabels = (
|
||||
sessions: readonly TerminalSession[],
|
||||
hosts: readonly Pick<Host, 'id' | 'label'>[],
|
||||
): TerminalSession[] => {
|
||||
if (hosts.length === 0) return sessions as TerminalSession[];
|
||||
const labelById = new Map<string, string>();
|
||||
for (const host of hosts) {
|
||||
if (host.id && host.label) labelById.set(host.id, host.label);
|
||||
}
|
||||
if (labelById.size === 0) return sessions as TerminalSession[];
|
||||
let changed = false;
|
||||
const next = sessions.map((session) => {
|
||||
if (session.customName?.trim()) return session;
|
||||
const label = session.hostId ? labelById.get(session.hostId) : undefined;
|
||||
if (label === undefined || session.hostLabel === label) return session;
|
||||
changed = true;
|
||||
return { ...session, hostLabel: label };
|
||||
});
|
||||
return changed ? next : (sessions as TerminalSession[]);
|
||||
};
|
||||
Reference in new issue
Block a user