feat: add multi-terminal workbar and resizable sidebars
The assistant workbar now uses persistent application tabs with a permanent catalog entry point and supports multiple local or managed SSH terminals bound to the current project. Terminal sessions flow through the Main, preload, and renderer boundaries with bounded output, resize, lifecycle, and cleanup behavior. The main sidebar and Magic Notes list now support pointer and keyboard resizing. Magic Notes can also hide and restore its list, remembers the chosen layout, and stacks its panes when the available width is narrow. Closing a terminal tab ends its Shell session. Restored terminal tabs do not restart prior Shell processes and require an explicit reconnect. Release note: 助手工作栏现可通过“+”打开多个本机或当前远程项目终端;主侧栏和魔法笔记列表支持拖拽调宽,魔法笔记列表可隐藏并记住布局。
This commit is contained in:
@@ -59,6 +59,8 @@ function createConnection(identity: SshPoolConnectionIdentity) {
|
||||
return {
|
||||
identity,
|
||||
isUsable: vi.fn(() => true),
|
||||
onDisconnect: vi.fn(() => () => undefined),
|
||||
openTerminalShell: vi.fn(),
|
||||
openAgentAttach: vi.fn(),
|
||||
runAgentDoctor: vi.fn(),
|
||||
runAgentBootstrapProbe: vi.fn(),
|
||||
@@ -403,6 +405,13 @@ describe("authenticated ssh2 connection", () => {
|
||||
callback(undefined, channel);
|
||||
},
|
||||
),
|
||||
shell: vi.fn(
|
||||
(
|
||||
_window: unknown,
|
||||
_options: unknown,
|
||||
callback: (error: Error | undefined, stream: typeof channel) => void,
|
||||
) => callback(undefined, channel),
|
||||
),
|
||||
sftp: vi.fn(),
|
||||
});
|
||||
const target = createTarget();
|
||||
@@ -425,6 +434,7 @@ describe("authenticated ssh2 connection", () => {
|
||||
);
|
||||
|
||||
await connection.openAgentAttach(verifyAgentInstallationId("agent-v1"));
|
||||
await connection.openTerminalShell({ cols: 132, rows: 43 });
|
||||
await expect(
|
||||
connection.probeRemotePackageBootstrap({
|
||||
operationId: "00000000-0000-4000-8000-000000000301",
|
||||
@@ -470,6 +480,17 @@ describe("authenticated ssh2 connection", () => {
|
||||
},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(client.shell).toHaveBeenCalledWith(
|
||||
{
|
||||
term: "xterm-256color",
|
||||
cols: 132,
|
||||
rows: 43,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
{ env: {}, x11: false },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(connection).not.toHaveProperty("exec");
|
||||
});
|
||||
|
||||
@@ -496,6 +517,7 @@ describe("authenticated ssh2 connection", () => {
|
||||
end: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
exec: vi.fn(),
|
||||
shell: vi.fn(),
|
||||
sftp: vi.fn(),
|
||||
});
|
||||
const target = createTarget();
|
||||
@@ -530,6 +552,7 @@ describe("authenticated ssh2 connection", () => {
|
||||
end: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
exec: vi.fn(),
|
||||
shell: vi.fn(),
|
||||
sftp: vi.fn(),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
@@ -569,6 +592,7 @@ describe("authenticated ssh2 connection", () => {
|
||||
}, 0);
|
||||
},
|
||||
),
|
||||
shell: vi.fn(),
|
||||
sftp: vi.fn(),
|
||||
});
|
||||
const target = createTarget();
|
||||
@@ -616,6 +640,7 @@ describe("authenticated ssh2 connection", () => {
|
||||
}, 0);
|
||||
},
|
||||
),
|
||||
shell: vi.fn(),
|
||||
sftp: vi.fn(),
|
||||
});
|
||||
const target = createTarget();
|
||||
@@ -673,6 +698,7 @@ describe("authenticated ssh2 connection", () => {
|
||||
}, 0);
|
||||
},
|
||||
),
|
||||
shell: vi.fn(),
|
||||
sftp: vi.fn(),
|
||||
});
|
||||
const target = createTarget();
|
||||
@@ -747,6 +773,7 @@ describe("authenticated ssh2 connection", () => {
|
||||
}, 0);
|
||||
},
|
||||
),
|
||||
shell: vi.fn(),
|
||||
sftp: vi.fn(),
|
||||
});
|
||||
const target = createTarget();
|
||||
|
||||
@@ -65,9 +65,20 @@ export type AgentDiagnosticResult = {
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
export type SshTerminalShellOptions = {
|
||||
cols: number;
|
||||
rows: number;
|
||||
term?: string;
|
||||
};
|
||||
|
||||
export interface AuthenticatedSshConnection {
|
||||
readonly identity: SshPoolConnectionIdentity;
|
||||
isUsable(): boolean;
|
||||
onDisconnect(listener: (error?: Error) => void): () => void;
|
||||
openTerminalShell(
|
||||
options: SshTerminalShellOptions,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientChannel>;
|
||||
openAgentAttach(
|
||||
installationId: VerifiedAgentInstallationId,
|
||||
signal?: AbortSignal,
|
||||
@@ -128,19 +139,29 @@ export interface AuthenticatedSshConnection {
|
||||
export interface SshConnectionLease extends Omit<
|
||||
AuthenticatedSshConnection,
|
||||
| "dispose"
|
||||
| "onDisconnect"
|
||||
| "openTerminalShell"
|
||||
| "createRemotePackageUploadStaging"
|
||||
| "prepareUploadedRemotePackageBootstrap"
|
||||
> {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface SshTerminalConnectionLease extends SshConnectionLease {
|
||||
onDisconnect(listener: (error?: Error) => void): () => void;
|
||||
openTerminalShell(
|
||||
options: SshTerminalShellOptions,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientChannel>;
|
||||
}
|
||||
|
||||
export interface SshRemotePackageBootstrapLease extends SshRemotePackageBootstrapExecutor {
|
||||
readonly identity: SshPoolConnectionIdentity;
|
||||
isUsable(): boolean;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
type InternalSshConnectionLease = SshConnectionLease &
|
||||
type InternalSshConnectionLease = SshTerminalConnectionLease &
|
||||
Pick<
|
||||
AuthenticatedSshConnection,
|
||||
| "createRemotePackageUploadStaging"
|
||||
@@ -149,7 +170,7 @@ type InternalSshConnectionLease = SshConnectionLease &
|
||||
|
||||
type ClientLike = Pick<
|
||||
Client,
|
||||
"connect" | "end" | "destroy" | "exec" | "sftp" | "on" | "once"
|
||||
"connect" | "end" | "destroy" | "exec" | "shell" | "sftp" | "on" | "once"
|
||||
>;
|
||||
|
||||
type SshConnectionDependencies = {
|
||||
@@ -285,19 +306,26 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection {
|
||||
private usable = true;
|
||||
private disposed = false;
|
||||
private agentBootstrapProbe: AgentBootstrapProbeResult | undefined;
|
||||
private readonly disconnectListeners = new Set<(error?: Error) => void>();
|
||||
|
||||
private constructor(
|
||||
readonly identity: SshPoolConnectionIdentity,
|
||||
private readonly client: ClientLike,
|
||||
private readonly controlPlanePackageInstaller: Buffer,
|
||||
) {
|
||||
const disconnected = (): void => {
|
||||
const disconnected = (error?: Error): void => {
|
||||
if (!this.usable) {
|
||||
return;
|
||||
}
|
||||
this.usable = false;
|
||||
this.agentBootstrapProbe = undefined;
|
||||
for (const listener of [...this.disconnectListeners]) {
|
||||
listener(error);
|
||||
}
|
||||
};
|
||||
this.client.on("close", disconnected);
|
||||
this.client.on("end", disconnected);
|
||||
this.client.on("error", disconnected);
|
||||
this.client.on("error", (error: Error) => disconnected(error));
|
||||
}
|
||||
|
||||
static connect(
|
||||
@@ -390,6 +418,70 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection {
|
||||
return this.usable;
|
||||
}
|
||||
|
||||
onDisconnect(listener: (error?: Error) => void): () => void {
|
||||
if (!this.usable) {
|
||||
queueMicrotask(() => listener());
|
||||
return () => undefined;
|
||||
}
|
||||
this.disconnectListeners.add(listener);
|
||||
return () => {
|
||||
this.disconnectListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
openTerminalShell(
|
||||
options: SshTerminalShellOptions,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ClientChannel> {
|
||||
this.assertUsable();
|
||||
if (
|
||||
!Number.isSafeInteger(options.cols) ||
|
||||
options.cols <= 0 ||
|
||||
!Number.isSafeInteger(options.rows) ||
|
||||
options.rows <= 0
|
||||
) {
|
||||
return Promise.reject(new Error("SSH PTY 尺寸无效"));
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(createAbortError(signal));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (error?: unknown, channel?: ClientChannel): void => {
|
||||
if (settled) {
|
||||
channel?.destroy();
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
if (error || !channel) {
|
||||
reject(error ?? new Error("无法打开 SSH 终端通道"));
|
||||
} else {
|
||||
resolve(channel);
|
||||
}
|
||||
};
|
||||
const abort = (): void => {
|
||||
finish(createAbortError(signal));
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
finish(new Error("SSH 终端通道打开超时"));
|
||||
}, CHANNEL_OPEN_TIMEOUT_MS);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
this.client.shell(
|
||||
{
|
||||
term: options.term ?? "xterm-256color",
|
||||
cols: options.cols,
|
||||
rows: options.rows,
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
{ env: {}, x11: false },
|
||||
(error, channel) => finish(error, channel),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async openAgentAttach(
|
||||
installationId: VerifiedAgentInstallationId,
|
||||
signal?: AbortSignal,
|
||||
@@ -584,6 +676,10 @@ export class Ssh2AuthenticatedConnection implements AuthenticatedSshConnection {
|
||||
this.disposed = true;
|
||||
this.usable = false;
|
||||
this.agentBootstrapProbe = undefined;
|
||||
for (const listener of [...this.disconnectListeners]) {
|
||||
listener();
|
||||
}
|
||||
this.disconnectListeners.clear();
|
||||
this.client.end();
|
||||
this.client.destroy();
|
||||
}
|
||||
@@ -881,6 +977,13 @@ export class SshConnectionPool {
|
||||
};
|
||||
}
|
||||
|
||||
async acquireTerminal(
|
||||
target: SshConnectionPoolTarget,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SshTerminalConnectionLease> {
|
||||
return (await this.acquire(target, signal)) as SshTerminalConnectionLease;
|
||||
}
|
||||
|
||||
disposeHost(hostId: string): void {
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.identity.hostId === hostId) {
|
||||
@@ -912,6 +1015,14 @@ export class SshConnectionPool {
|
||||
const lease: InternalSshConnectionLease = {
|
||||
identity: connection.identity,
|
||||
isUsable: () => !released && connection.isUsable(),
|
||||
onDisconnect: (listener) => {
|
||||
assertActive();
|
||||
return connection.onDisconnect(listener);
|
||||
},
|
||||
openTerminalShell: (options, signal) => {
|
||||
assertActive();
|
||||
return connection.openTerminalShell(options, signal);
|
||||
},
|
||||
openAgentAttach: (installationId, signal) => {
|
||||
assertActive();
|
||||
return connection.openAgentAttach(installationId, signal);
|
||||
|
||||
Reference in New Issue
Block a user