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:
@@ -1,4 +1,4 @@
|
||||
const runtimeProviderEnvironmentNames = [
|
||||
export const runtimeProviderEnvironmentNames = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
|
||||
+24
-1
@@ -156,6 +156,7 @@ import {
|
||||
DesktopDiagnostics,
|
||||
type DesktopDiagnosticFailureObserver
|
||||
} from './desktop-diagnostics'
|
||||
import { TerminalSessionManager } from './terminal/terminal-session-manager'
|
||||
|
||||
const legacyDefaultShortcut =
|
||||
defaultGlobalShortcutSettings.accelerator
|
||||
@@ -215,6 +216,7 @@ const embeddingBrokers = new Set<EmbeddingInferenceBroker>()
|
||||
let stopRuntimeReconfiguration: (() => Promise<void>) | undefined
|
||||
let dshExtensionInstaller: DshNpmExtensionInstaller | undefined
|
||||
let remoteAgentServices: RemoteAgentServices | undefined
|
||||
let terminalSessionManager: TerminalSessionManager | undefined
|
||||
let managedRemoteExecutionServices:
|
||||
| ManagedRemoteExecutionServices
|
||||
| undefined
|
||||
@@ -1302,6 +1304,25 @@ if (hasSingleInstanceLock) {
|
||||
await Promise.all(invalidations)
|
||||
}
|
||||
)
|
||||
terminalSessionManager = new TerminalSessionManager({
|
||||
database: startupAssistantDatabase,
|
||||
executionSpaceResolver,
|
||||
targetResolver: startupRemoteAgentServices.targetResolver,
|
||||
sshPool: startupRemoteAgentServices.sshPool,
|
||||
remoteEnabled: async () =>
|
||||
(await applicationSettingsStore.get()).remoteProjectsEnabled,
|
||||
deliverEvent: (ownerWebContentsId, event) => {
|
||||
const targetWindow = mainWindow
|
||||
if (
|
||||
targetWindow &&
|
||||
!targetWindow.isDestroyed() &&
|
||||
targetWindow.webContents.id === ownerWebContentsId &&
|
||||
!targetWindow.webContents.isDestroyed()
|
||||
) {
|
||||
targetWindow.webContents.send(ipcChannels.terminalEvent, event)
|
||||
}
|
||||
}
|
||||
})
|
||||
removeIpcHandlers = registerIpcHandlers(
|
||||
mainWindow,
|
||||
runtime,
|
||||
@@ -1359,7 +1380,8 @@ if (hasSingleInstanceLock) {
|
||||
setCurrentEmbeddingConnection,
|
||||
remoteEnvironmentUpdateService,
|
||||
agentPackageManager,
|
||||
startupRemoteAgentServices.connectionManager
|
||||
startupRemoteAgentServices.connectionManager,
|
||||
terminalSessionManager
|
||||
)
|
||||
removeFeedbackIpcHandler = registerFeedbackIpcHandler(
|
||||
mainWindow,
|
||||
@@ -1473,6 +1495,7 @@ app.on('before-quit', (event) => {
|
||||
() => documentOcrModelManager?.dispose(),
|
||||
() => documentOcrBroker?.dispose()
|
||||
],
|
||||
[() => terminalSessionManager?.dispose()],
|
||||
[() => managedRemoteExecutionServices?.dispose()],
|
||||
[() => remoteAgentServices?.dispose()],
|
||||
[() => knowledgeGateway?.dispose()],
|
||||
|
||||
@@ -55,6 +55,196 @@ describe('embedding IPC boundary', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal IPC boundary', () => {
|
||||
afterEach(() => {
|
||||
electronMocks.handlers.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('validates terminal requests and routes them through the trusted renderer owner', async () => {
|
||||
const webContents = {
|
||||
id: 73,
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
getURL: vi.fn(() => 'file:///goodbuddy/index.html'),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
send: vi.fn()
|
||||
}
|
||||
const window = {
|
||||
webContents,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => false),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}
|
||||
const snapshot = {
|
||||
sessionId: '00000000-0000-4000-8000-000000000073',
|
||||
target: { type: 'local' as const },
|
||||
title: 'PowerShell',
|
||||
state: 'running' as const,
|
||||
cwd: 'C:\\Workspace',
|
||||
cols: 120,
|
||||
rows: 36,
|
||||
lastSequence: 0
|
||||
}
|
||||
const terminalSessionManager = {
|
||||
create: vi.fn(async () => snapshot),
|
||||
enableEventDelivery: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
snapshot: vi.fn(() => snapshot),
|
||||
acknowledge: vi.fn(),
|
||||
close: vi.fn(async () => ({
|
||||
...snapshot,
|
||||
state: 'closed' as const
|
||||
})),
|
||||
closeOwner: vi.fn(async () => undefined)
|
||||
}
|
||||
const args: Parameters<typeof registerIpcHandlers> = [
|
||||
window as never,
|
||||
{ capability: 'text' } as never,
|
||||
'CommandOrControl+Shift+Space',
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
{
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => [])
|
||||
} as never,
|
||||
{ clear: vi.fn() } as never,
|
||||
{} as never,
|
||||
vi.fn(async () => {}),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
terminalSessionManager as never
|
||||
]
|
||||
const dispose = registerIpcHandlers(...args)
|
||||
const event = {
|
||||
sender: webContents,
|
||||
senderFrame: webContents.mainFrame
|
||||
}
|
||||
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.terminalCreate)?.(event, {
|
||||
target: { type: 'local' },
|
||||
cols: 120,
|
||||
rows: 36
|
||||
})
|
||||
).resolves.toEqual(snapshot)
|
||||
expect(terminalSessionManager.create).toHaveBeenCalledWith(73, {
|
||||
target: { type: 'local' },
|
||||
cols: 120,
|
||||
rows: 36
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
terminalSessionManager.enableEventDelivery
|
||||
).toHaveBeenCalledWith(73, snapshot.sessionId)
|
||||
)
|
||||
|
||||
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(event, {
|
||||
sessionId: snapshot.sessionId,
|
||||
data: 'echo ready\r'
|
||||
})
|
||||
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
|
||||
sessionId: snapshot.sessionId,
|
||||
cols: 100,
|
||||
rows: 28
|
||||
})
|
||||
electronMocks.handlers.get(ipcChannels.terminalAck)?.(event, {
|
||||
sessionId: snapshot.sessionId,
|
||||
sequence: 4
|
||||
})
|
||||
expect(
|
||||
electronMocks.handlers.get(ipcChannels.terminalSnapshot)?.(
|
||||
event,
|
||||
{ sessionId: snapshot.sessionId }
|
||||
)
|
||||
).toEqual(snapshot)
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.terminalClose)?.(event, {
|
||||
sessionId: snapshot.sessionId
|
||||
})
|
||||
).resolves.toMatchObject({ state: 'closed' })
|
||||
|
||||
expect(terminalSessionManager.write).toHaveBeenCalledWith(
|
||||
73,
|
||||
snapshot.sessionId,
|
||||
'echo ready\r'
|
||||
)
|
||||
expect(terminalSessionManager.resize).toHaveBeenCalledWith(
|
||||
73,
|
||||
snapshot.sessionId,
|
||||
{ cols: 100, rows: 28 }
|
||||
)
|
||||
expect(terminalSessionManager.acknowledge).toHaveBeenCalledWith(
|
||||
73,
|
||||
snapshot.sessionId,
|
||||
4
|
||||
)
|
||||
expect(terminalSessionManager.snapshot).toHaveBeenCalledWith(
|
||||
73,
|
||||
snapshot.sessionId
|
||||
)
|
||||
expect(terminalSessionManager.close).toHaveBeenCalledWith(
|
||||
73,
|
||||
snapshot.sessionId
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
electronMocks.handlers.get(ipcChannels.terminalWrite)?.(
|
||||
{
|
||||
sender: { ...webContents, id: 99 },
|
||||
senderFrame: webContents.mainFrame
|
||||
},
|
||||
{
|
||||
sessionId: snapshot.sessionId,
|
||||
data: 'untrusted'
|
||||
}
|
||||
)
|
||||
).toThrow('拒绝来自未知窗口的 IPC 请求')
|
||||
expect(() =>
|
||||
electronMocks.handlers.get(ipcChannels.terminalResize)?.(event, {
|
||||
sessionId: snapshot.sessionId,
|
||||
cols: 0,
|
||||
rows: 28
|
||||
})
|
||||
).toThrow()
|
||||
|
||||
await dispose()
|
||||
expect(terminalSessionManager.closeOwner).toHaveBeenCalledWith(73)
|
||||
})
|
||||
})
|
||||
|
||||
type InvokeHandler = (event: unknown, input?: unknown) => unknown
|
||||
type KnowledgeGrantMock = (
|
||||
requestId: string,
|
||||
|
||||
+105
-1
@@ -83,6 +83,14 @@ import {
|
||||
knowledgeTaskItemSchema
|
||||
} from '../shared/knowledge-task-contracts'
|
||||
import { ipcChannels } from '../shared/ipc-channels'
|
||||
import {
|
||||
terminalAckRequestSchema,
|
||||
terminalCloseRequestSchema,
|
||||
terminalCreateRequestSchema,
|
||||
terminalResizeRequestSchema,
|
||||
terminalSnapshotRequestSchema,
|
||||
terminalWriteRequestSchema
|
||||
} from '../shared/terminal-contracts'
|
||||
import {
|
||||
browserProfileCreateInputSchema,
|
||||
browserProfileRenameInputSchema,
|
||||
@@ -119,6 +127,7 @@ import {
|
||||
agentPackageInventorySchema
|
||||
} from '../shared/agent-package-contracts'
|
||||
import { assertTrustedSender } from './trusted-ipc-sender'
|
||||
import type { TerminalSessionManager } from './terminal/terminal-session-manager'
|
||||
import { releaseNotesAcknowledgeSchema } from '../shared/release-notes-contracts'
|
||||
import {
|
||||
speechModelActionInputSchema,
|
||||
@@ -1088,7 +1097,8 @@ export function registerIpcHandlers(
|
||||
remoteAgentConnectionManager?: Pick<
|
||||
RemoteAgentConnectionManager,
|
||||
'getHostConnectionState' | 'onHostConnectionStateChange'
|
||||
>
|
||||
>,
|
||||
terminalSessionManager?: TerminalSessionManager
|
||||
): () => Promise<void> {
|
||||
type ActiveRequestLease = {
|
||||
controller: AbortController
|
||||
@@ -1097,6 +1107,12 @@ export function registerIpcHandlers(
|
||||
release(): void
|
||||
}
|
||||
const activeRequests = new Map<string, ActiveRequestLease>()
|
||||
const requireTerminalSessionManager = (): TerminalSessionManager => {
|
||||
if (!terminalSessionManager) {
|
||||
throw new Error('终端服务不可用')
|
||||
}
|
||||
return terminalSessionManager
|
||||
}
|
||||
const activeRequestConversations = new Map<
|
||||
string,
|
||||
ActiveRequestLease
|
||||
@@ -1224,6 +1240,7 @@ export function registerIpcHandlers(
|
||||
(channel) =>
|
||||
channel !== ipcChannels.agentEvent &&
|
||||
channel !== ipcChannels.browserState &&
|
||||
channel !== ipcChannels.terminalEvent &&
|
||||
channel !== ipcChannels.conversationNew &&
|
||||
channel !== ipcChannels.settingsOpen &&
|
||||
channel !== ipcChannels.versionCheckResult &&
|
||||
@@ -3347,6 +3364,92 @@ export function registerIpcHandlers(
|
||||
])
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.terminalCreate,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const snapshot = await manager.create(
|
||||
event.sender.id,
|
||||
terminalCreateRequestSchema.parse(input)
|
||||
)
|
||||
setImmediate(() => {
|
||||
if (
|
||||
!window.isDestroyed() &&
|
||||
event.sender === window.webContents &&
|
||||
!event.sender.isDestroyed()
|
||||
) {
|
||||
manager.enableEventDelivery(
|
||||
event.sender.id,
|
||||
snapshot.sessionId
|
||||
)
|
||||
}
|
||||
})
|
||||
return snapshot
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(ipcChannels.terminalWrite, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const request = terminalWriteRequestSchema.parse(input)
|
||||
manager.write(
|
||||
event.sender.id,
|
||||
request.sessionId,
|
||||
request.data
|
||||
)
|
||||
})
|
||||
|
||||
registerHandler(ipcChannels.terminalResize, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const request = terminalResizeRequestSchema.parse(input)
|
||||
manager.resize(event.sender.id, request.sessionId, {
|
||||
cols: request.cols,
|
||||
rows: request.rows
|
||||
})
|
||||
})
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.terminalSnapshot,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const request = terminalSnapshotRequestSchema.parse(input)
|
||||
return manager.snapshot(
|
||||
event.sender.id,
|
||||
request.sessionId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.terminalAck,
|
||||
(event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const request = terminalAckRequestSchema.parse(input)
|
||||
manager.acknowledge(
|
||||
event.sender.id,
|
||||
request.sessionId,
|
||||
request.sequence
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.terminalClose,
|
||||
async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const manager = requireTerminalSessionManager()
|
||||
const request = terminalCloseRequestSchema.parse(input)
|
||||
return manager.close(
|
||||
event.sender.id,
|
||||
request.sessionId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
registerHandler(
|
||||
ipcChannels.browserInteract,
|
||||
async (event, input: unknown) => {
|
||||
@@ -7769,6 +7872,7 @@ export function registerIpcHandlers(
|
||||
approvalBroker.clear()
|
||||
goodbuddyConfigService?.clear()
|
||||
pendingGoodBuddyConfigReload = false
|
||||
await terminalSessionManager?.closeOwner(window.webContents.id)
|
||||
await requestRendererPersistence()
|
||||
await waitForRendererQuiescence()
|
||||
for (const channel of channels) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import type { IDisposable, IPty } from 'node-pty'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TERMINAL_LIMITS,
|
||||
type TerminalEvent
|
||||
} from '../../shared/terminal-contracts'
|
||||
import {
|
||||
LocalTerminalSession,
|
||||
buildLocalTerminalEnvironment,
|
||||
resolveLocalTerminalLaunch,
|
||||
resolveLocalTerminalShell
|
||||
} from './local-terminal-session'
|
||||
|
||||
class FakePty implements IPty {
|
||||
readonly pid = 4321
|
||||
readonly cols = 80
|
||||
readonly rows = 24
|
||||
readonly process = 'fake-shell'
|
||||
handleFlowControl = false
|
||||
readonly write = vi.fn()
|
||||
readonly resize = vi.fn()
|
||||
readonly clear = vi.fn()
|
||||
readonly kill = vi.fn()
|
||||
readonly pause = vi.fn()
|
||||
readonly resume = vi.fn()
|
||||
private dataListeners = new Set<(data: string) => void>()
|
||||
private exitListeners = new Set<
|
||||
(event: { exitCode: number; signal?: number }) => void
|
||||
>()
|
||||
|
||||
readonly onData = (
|
||||
listener: (data: string) => void
|
||||
): IDisposable => {
|
||||
this.dataListeners.add(listener)
|
||||
return {
|
||||
dispose: () => this.dataListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
readonly onExit = (
|
||||
listener: (event: {
|
||||
exitCode: number
|
||||
signal?: number
|
||||
}) => void
|
||||
): IDisposable => {
|
||||
this.exitListeners.add(listener)
|
||||
return {
|
||||
dispose: () => this.exitListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
emitData(data: string): void {
|
||||
for (const listener of this.dataListeners) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
|
||||
emitExit(exitCode: number, signal?: number): void {
|
||||
for (const listener of [...this.exitListeners]) {
|
||||
listener({ exitCode, signal })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const baseOptions = {
|
||||
sessionId: '5d8f47a2-c681-4c26-95a2-2ed1a086fc78',
|
||||
target: { type: 'local' } as const,
|
||||
targetLabel: '本机',
|
||||
title: '终端 · 本机 1',
|
||||
size: { cols: 80, rows: 24 }
|
||||
}
|
||||
|
||||
function sessionDependencies(
|
||||
pty: FakePty,
|
||||
overrides: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
platform: 'linux' as const,
|
||||
environment: {
|
||||
SHELL: '/bin/bash',
|
||||
PATH: '/bin',
|
||||
SAFE_VALUE: 'visible'
|
||||
},
|
||||
executableExists: async (path: string) => path === '/bin/bash',
|
||||
directoryExists: async () => true,
|
||||
homeDirectory: () => '/home/tester',
|
||||
spawn: vi.fn(() => pty),
|
||||
terminate: vi.fn(async () => {
|
||||
pty.emitExit(0)
|
||||
}),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('local terminal launch resolution', () => {
|
||||
it('prefers pwsh, then PowerShell, then COMSPEC on Windows', async () => {
|
||||
const available = new Set(['C:\\bin\\powershell.EXE'])
|
||||
const shell = await resolveLocalTerminalShell({
|
||||
platform: 'win32',
|
||||
environment: {
|
||||
Path: 'C:\\bin',
|
||||
PATHEXT: '.EXE',
|
||||
COMSPEC: 'C:\\Windows\\cmd.exe'
|
||||
},
|
||||
executableExists: async (path) => available.has(path)
|
||||
})
|
||||
|
||||
expect(shell).toEqual({
|
||||
shell: 'C:\\bin\\powershell.EXE',
|
||||
shellLabel: 'powershell.EXE'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a valid POSIX SHELL and falls back to home for invalid cwd', async () => {
|
||||
const launch = await resolveLocalTerminalLaunch('/missing', {
|
||||
platform: 'linux',
|
||||
environment: {
|
||||
SHELL: '/usr/bin/fish',
|
||||
PATH: '/usr/bin',
|
||||
LANG: 'zh_CN.UTF-8',
|
||||
GOODBUDDY_MODEL_API_KEY: 'secret',
|
||||
ANTHROPIC_API_KEY: 'secret'
|
||||
},
|
||||
executableExists: async (path) => path === '/usr/bin/fish',
|
||||
directoryExists: async (path) => path === '/home/tester',
|
||||
homeDirectory: () => '/home/tester'
|
||||
})
|
||||
|
||||
expect(launch.cwd).toBe('/home/tester')
|
||||
expect(launch.shell).toBe('/usr/bin/fish')
|
||||
expect(launch.env).toMatchObject({
|
||||
SHELL: '/usr/bin/fish',
|
||||
LANG: 'zh_CN.UTF-8'
|
||||
})
|
||||
expect(launch.env).not.toHaveProperty('GOODBUDDY_MODEL_API_KEY')
|
||||
expect(launch.env).not.toHaveProperty('ANTHROPIC_API_KEY')
|
||||
})
|
||||
|
||||
it('filters product and model credentials without dropping normal variables', () => {
|
||||
expect(
|
||||
buildLocalTerminalEnvironment({
|
||||
PATH: '/bin',
|
||||
CUSTOM_TOKEN: 'user-owned',
|
||||
FACTORY_API_KEY: 'internal',
|
||||
OPENAI_API_KEY: 'model'
|
||||
})
|
||||
).toEqual({
|
||||
PATH: '/bin',
|
||||
CUSTOM_TOKEN: 'user-owned'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalTerminalSession', () => {
|
||||
it('spawns the PTY and preserves ordered state, output, exit events', async () => {
|
||||
const pty = new FakePty()
|
||||
const dependencies = sessionDependencies(pty)
|
||||
const session = await LocalTerminalSession.create({
|
||||
...baseOptions,
|
||||
dependencies
|
||||
})
|
||||
const events: TerminalEvent[] = []
|
||||
session.onEvent((event) => events.push(event))
|
||||
|
||||
pty.emitData('你好')
|
||||
pty.emitExit(7, 15)
|
||||
|
||||
expect(dependencies.spawn).toHaveBeenCalledWith(
|
||||
'/bin/bash',
|
||||
[],
|
||||
expect.objectContaining({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/tester',
|
||||
name: 'xterm-256color'
|
||||
})
|
||||
)
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
'state',
|
||||
'state',
|
||||
'output',
|
||||
'exit',
|
||||
'state'
|
||||
])
|
||||
expect(events.map((event) => event.sequence)).toEqual([
|
||||
1, 2, 3, 4, 5
|
||||
])
|
||||
expect(session.snapshot()).toMatchObject({
|
||||
state: 'exited',
|
||||
exit: { exitCode: 7, signal: '15' },
|
||||
lastSequence: 5
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards writes and deduplicates resize while running', async () => {
|
||||
const pty = new FakePty()
|
||||
const session = await LocalTerminalSession.create({
|
||||
...baseOptions,
|
||||
dependencies: sessionDependencies(pty)
|
||||
})
|
||||
|
||||
expect(session.write('echo ok\r')).toBe(true)
|
||||
expect(session.resize({ cols: 80, rows: 24 })).toBe(true)
|
||||
expect(session.resize({ cols: 120, rows: 40 })).toBe(true)
|
||||
|
||||
expect(pty.write).toHaveBeenCalledWith('echo ok\r')
|
||||
expect(pty.resize).toHaveBeenCalledTimes(1)
|
||||
expect(pty.resize).toHaveBeenCalledWith(120, 40)
|
||||
expect(session.snapshot().size).toEqual({
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
})
|
||||
|
||||
it('splits output events at the UTF-8 boundary', async () => {
|
||||
const pty = new FakePty()
|
||||
const session = await LocalTerminalSession.create({
|
||||
...baseOptions,
|
||||
dependencies: sessionDependencies(pty)
|
||||
})
|
||||
const output: string[] = []
|
||||
session.onEvent((event) => {
|
||||
if (event.type === 'output') {
|
||||
output.push(event.data)
|
||||
}
|
||||
})
|
||||
const data =
|
||||
'a'.repeat(TERMINAL_LIMITS.maximumEventBytes - 1) + '你'
|
||||
|
||||
pty.emitData(data)
|
||||
|
||||
expect(output.join('')).toBe(data)
|
||||
expect(
|
||||
output.every(
|
||||
(chunk) =>
|
||||
Buffer.byteLength(chunk) <=
|
||||
TERMINAL_LIMITS.maximumEventBytes
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('pauses at the pending-event boundary and resumes after ACK', async () => {
|
||||
const pty = new FakePty()
|
||||
const session = await LocalTerminalSession.create({
|
||||
...baseOptions,
|
||||
dependencies: sessionDependencies(pty)
|
||||
})
|
||||
let lastSequence = 0
|
||||
session.onEvent((event) => {
|
||||
lastSequence = event.sequence
|
||||
})
|
||||
|
||||
for (
|
||||
let index = 0;
|
||||
index < TERMINAL_LIMITS.maximumPendingEvents;
|
||||
index += 1
|
||||
) {
|
||||
pty.emitData('x')
|
||||
if (pty.pause.mock.calls.length > 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(pty.pause).toHaveBeenCalledTimes(1)
|
||||
session.acknowledge(lastSequence)
|
||||
expect(pty.resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports launch failures with stable failed state', async () => {
|
||||
const session = await LocalTerminalSession.create({
|
||||
...baseOptions,
|
||||
dependencies: {
|
||||
platform: 'linux',
|
||||
environment: { SHELL: '/missing' },
|
||||
executableExists: async () => false,
|
||||
directoryExists: async () => true,
|
||||
homeDirectory: () => '/home/tester'
|
||||
}
|
||||
})
|
||||
const events: TerminalEvent[] = []
|
||||
session.onEvent((event) => events.push(event))
|
||||
|
||||
expect(session.snapshot()).toMatchObject({
|
||||
state: 'failed',
|
||||
error: { code: 'launch-failed', retryable: true }
|
||||
})
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
'state',
|
||||
'error',
|
||||
'state'
|
||||
])
|
||||
expect(session.write('ignored')).toBe(false)
|
||||
})
|
||||
|
||||
it('closes once, rejects late operations, and keeps a stable exit', async () => {
|
||||
const pty = new FakePty()
|
||||
const dependencies = sessionDependencies(pty)
|
||||
const session = await LocalTerminalSession.create({
|
||||
...baseOptions,
|
||||
dependencies
|
||||
})
|
||||
|
||||
const firstClose = session.close()
|
||||
const secondClose = session.close()
|
||||
expect(secondClose).toBe(firstClose)
|
||||
await firstClose
|
||||
|
||||
expect(dependencies.terminate).toHaveBeenCalledTimes(1)
|
||||
expect(session.write('late')).toBe(false)
|
||||
expect(session.resize({ cols: 90, rows: 30 })).toBe(false)
|
||||
expect(session.snapshot()).toMatchObject({
|
||||
state: 'exited',
|
||||
exit: { exitCode: 0, signal: null }
|
||||
})
|
||||
await session.close()
|
||||
expect(dependencies.terminate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,672 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { constants as fileSystemConstants } from 'node:fs'
|
||||
import { access, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { posix, win32 } from 'node:path'
|
||||
import * as nodePty from 'node-pty'
|
||||
import type { IDisposable, IPty } from 'node-pty'
|
||||
import {
|
||||
TERMINAL_LIMITS,
|
||||
type TerminalError,
|
||||
type TerminalEvent,
|
||||
type TerminalExit,
|
||||
type TerminalSize,
|
||||
type TerminalSnapshot,
|
||||
type TerminalTarget
|
||||
} from '../../shared/terminal-contracts'
|
||||
import { requestProcessTreeTermination } from '../agent/child-process-termination'
|
||||
import { runtimeProviderEnvironmentNames } from '../agent/process-environment'
|
||||
|
||||
export type LocalTerminalTarget = Extract<
|
||||
TerminalTarget,
|
||||
{ type: 'local' | 'project' }
|
||||
>
|
||||
|
||||
export type LocalTerminalLaunch = {
|
||||
cwd: string
|
||||
shell: string
|
||||
shellLabel: string
|
||||
env: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
export type LocalPtySpawn = (
|
||||
file: string,
|
||||
args: string[],
|
||||
options: nodePty.IPtyForkOptions
|
||||
) => IPty
|
||||
|
||||
export type LocalTerminalDependencies = {
|
||||
platform?: NodeJS.Platform
|
||||
environment?: NodeJS.ProcessEnv
|
||||
homeDirectory?: () => string
|
||||
executableExists?: (path: string) => Promise<boolean>
|
||||
directoryExists?: (path: string) => Promise<boolean>
|
||||
spawn?: LocalPtySpawn
|
||||
terminate?: (pty: IPty, platform: NodeJS.Platform) => Promise<void>
|
||||
}
|
||||
|
||||
export type LocalTerminalSessionOptions = {
|
||||
sessionId?: string
|
||||
target: LocalTerminalTarget
|
||||
targetLabel: string
|
||||
title: string
|
||||
size: TerminalSize
|
||||
projectDirectory?: string
|
||||
dependencies?: LocalTerminalDependencies
|
||||
}
|
||||
|
||||
export type LocalTerminalEventListener = (event: TerminalEvent) => void
|
||||
type UnsequencedTerminalEvent<T> = T extends TerminalEvent
|
||||
? Omit<T, 'sessionId' | 'sequence'>
|
||||
: never
|
||||
type LocalTerminalEvent = UnsequencedTerminalEvent<TerminalEvent>
|
||||
|
||||
const CONTROL_EVENT_RESERVE = 2
|
||||
const PAUSE_PENDING_EVENT_COUNT =
|
||||
TERMINAL_LIMITS.maximumPendingEvents - CONTROL_EVENT_RESERVE
|
||||
const RESUME_PENDING_EVENT_COUNT = Math.floor(
|
||||
PAUSE_PENDING_EVENT_COUNT / 2
|
||||
)
|
||||
const CLOSE_WAIT_MS = 1_500
|
||||
const FORCE_WAIT_MS = 250
|
||||
|
||||
const credentialEnvironmentNames = new Set([
|
||||
...runtimeProviderEnvironmentNames,
|
||||
'DEEPSEEK_API_KEY',
|
||||
'DASHSCOPE_API_KEY',
|
||||
'TOGETHER_API_KEY',
|
||||
'FIREWORKS_API_KEY',
|
||||
'PERPLEXITY_API_KEY',
|
||||
'CEREBRAS_API_KEY',
|
||||
'CLAUDE_CODE_OAUTH_TOKEN'
|
||||
])
|
||||
|
||||
function isCredentialEnvironmentName(name: string): boolean {
|
||||
const upperName = name.toUpperCase()
|
||||
return (
|
||||
upperName.startsWith('GOODBUDDY_') ||
|
||||
upperName.startsWith('FACTORY_') ||
|
||||
credentialEnvironmentNames.has(upperName)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the user's normal interactive environment while removing credentials
|
||||
* inherited by Electron Main for GoodBuddy and model-provider operation.
|
||||
*/
|
||||
export function buildLocalTerminalEnvironment(
|
||||
source: NodeJS.ProcessEnv = process.env
|
||||
): NodeJS.ProcessEnv {
|
||||
const environment: NodeJS.ProcessEnv = {}
|
||||
for (const [name, value] of Object.entries(source)) {
|
||||
if (value !== undefined && !isCredentialEnvironmentName(name)) {
|
||||
environment[name] = value
|
||||
}
|
||||
}
|
||||
return environment
|
||||
}
|
||||
|
||||
async function defaultExecutableExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(
|
||||
path,
|
||||
process.platform === 'win32'
|
||||
? fileSystemConstants.F_OK
|
||||
: fileSystemConstants.X_OK
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultDirectoryExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function findExecutableOnPath(
|
||||
name: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform,
|
||||
exists: (path: string) => Promise<boolean>
|
||||
): Promise<string | undefined> {
|
||||
const pathApi = platform === 'win32' ? win32 : posix
|
||||
if (pathApi.isAbsolute(name)) {
|
||||
return (await exists(name)) ? name : undefined
|
||||
}
|
||||
const pathValue =
|
||||
environment.PATH ?? environment.Path ?? environment.path ?? ''
|
||||
const extensions =
|
||||
platform === 'win32'
|
||||
? (environment.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
|
||||
.split(';')
|
||||
.filter(Boolean)
|
||||
: ['']
|
||||
const pathDelimiter = platform === 'win32' ? ';' : ':'
|
||||
for (const directory of pathValue.split(pathDelimiter).filter(Boolean)) {
|
||||
for (const extension of extensions) {
|
||||
const candidate = pathApi.join(directory, `${name}${extension}`)
|
||||
if (await exists(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function resolveLocalTerminalShell(
|
||||
dependencies: LocalTerminalDependencies = {}
|
||||
): Promise<{ shell: string; shellLabel: string }> {
|
||||
const platform = dependencies.platform ?? process.platform
|
||||
const environment = dependencies.environment ?? process.env
|
||||
const exists = dependencies.executableExists ?? defaultExecutableExists
|
||||
let shell: string | undefined
|
||||
|
||||
if (platform === 'win32') {
|
||||
shell =
|
||||
(await findExecutableOnPath(
|
||||
'pwsh',
|
||||
environment,
|
||||
platform,
|
||||
exists
|
||||
)) ??
|
||||
(await findExecutableOnPath(
|
||||
'powershell',
|
||||
environment,
|
||||
platform,
|
||||
exists
|
||||
))
|
||||
if (!shell) {
|
||||
const comspec = environment.COMSPEC?.trim()
|
||||
shell = comspec
|
||||
? await findExecutableOnPath(
|
||||
comspec,
|
||||
environment,
|
||||
platform,
|
||||
exists
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
} else {
|
||||
const configuredShell = environment.SHELL?.trim()
|
||||
if (
|
||||
configuredShell &&
|
||||
posix.isAbsolute(configuredShell) &&
|
||||
(await exists(configuredShell))
|
||||
) {
|
||||
shell = configuredShell
|
||||
} else {
|
||||
const fallbacks =
|
||||
platform === 'darwin'
|
||||
? ['/bin/zsh', '/bin/bash', '/bin/sh']
|
||||
: ['/bin/bash', '/bin/sh']
|
||||
for (const candidate of fallbacks) {
|
||||
if (await exists(candidate)) {
|
||||
shell = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shell) {
|
||||
throw new Error('No supported interactive shell is available')
|
||||
}
|
||||
return {
|
||||
shell,
|
||||
shellLabel:
|
||||
platform === 'win32'
|
||||
? win32.basename(shell)
|
||||
: posix.basename(shell)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveLocalTerminalWorkingDirectory(
|
||||
projectDirectory: string | undefined,
|
||||
dependencies: LocalTerminalDependencies = {}
|
||||
): Promise<string> {
|
||||
const isDirectory =
|
||||
dependencies.directoryExists ?? defaultDirectoryExists
|
||||
const home = (dependencies.homeDirectory ?? homedir)()
|
||||
if (
|
||||
projectDirectory?.trim() &&
|
||||
(await isDirectory(projectDirectory))
|
||||
) {
|
||||
return projectDirectory
|
||||
}
|
||||
if (await isDirectory(home)) {
|
||||
return home
|
||||
}
|
||||
throw new Error('No valid local terminal working directory is available')
|
||||
}
|
||||
|
||||
export async function resolveLocalTerminalLaunch(
|
||||
projectDirectory: string | undefined,
|
||||
dependencies: LocalTerminalDependencies = {}
|
||||
): Promise<LocalTerminalLaunch> {
|
||||
const [shell, cwd] = await Promise.all([
|
||||
resolveLocalTerminalShell(dependencies),
|
||||
resolveLocalTerminalWorkingDirectory(
|
||||
projectDirectory,
|
||||
dependencies
|
||||
)
|
||||
])
|
||||
return {
|
||||
...shell,
|
||||
cwd,
|
||||
env: buildLocalTerminalEnvironment(
|
||||
dependencies.environment ?? process.env
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function splitBoundedOutput(data: string): string[] {
|
||||
const encoded = Buffer.from(data)
|
||||
const chunks: string[] = []
|
||||
let offset = 0
|
||||
while (offset < encoded.byteLength) {
|
||||
let end = Math.min(
|
||||
offset + TERMINAL_LIMITS.maximumEventBytes,
|
||||
encoded.byteLength
|
||||
)
|
||||
if (end < encoded.byteLength) {
|
||||
while (
|
||||
end > offset &&
|
||||
(encoded[end]! & 0xc0) === 0x80
|
||||
) {
|
||||
end -= 1
|
||||
}
|
||||
}
|
||||
if (end === offset) {
|
||||
end = Math.min(offset + 1, encoded.byteLength)
|
||||
}
|
||||
chunks.push(encoded.subarray(offset, end).toString('utf8'))
|
||||
offset = end
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => boolean,
|
||||
milliseconds: number
|
||||
): Promise<void> {
|
||||
if (predicate()) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
const finish = (): void => {
|
||||
clearInterval(interval)
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
if (predicate()) {
|
||||
finish()
|
||||
}
|
||||
}, 10)
|
||||
const timeout = setTimeout(finish, milliseconds)
|
||||
interval.unref?.()
|
||||
timeout.unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
async function defaultTerminate(
|
||||
pty: IPty,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<void> {
|
||||
let exited = false
|
||||
const exitSubscription = pty.onExit(() => {
|
||||
exited = true
|
||||
})
|
||||
requestProcessTreeTermination(
|
||||
{
|
||||
pid: pty.pid,
|
||||
killed: false,
|
||||
exitCode: null,
|
||||
kill: (signal) =>
|
||||
platform === 'win32' ? pty.kill() : pty.kill(signal)
|
||||
},
|
||||
{
|
||||
platform,
|
||||
processGroup: platform !== 'win32',
|
||||
waitMs: CLOSE_WAIT_MS
|
||||
}
|
||||
)
|
||||
await waitUntil(() => exited, CLOSE_WAIT_MS)
|
||||
if (!exited) {
|
||||
requestProcessTreeTermination(
|
||||
{
|
||||
pid: pty.pid,
|
||||
killed: false,
|
||||
exitCode: null,
|
||||
kill: () =>
|
||||
platform === 'win32'
|
||||
? pty.kill()
|
||||
: pty.kill('SIGKILL')
|
||||
},
|
||||
{
|
||||
platform,
|
||||
processGroup: platform !== 'win32',
|
||||
signal: 'SIGKILL',
|
||||
waitMs: FORCE_WAIT_MS
|
||||
}
|
||||
)
|
||||
await waitUntil(() => exited, FORCE_WAIT_MS)
|
||||
}
|
||||
exitSubscription.dispose()
|
||||
}
|
||||
|
||||
export class LocalTerminalSession {
|
||||
static async create(
|
||||
options: LocalTerminalSessionOptions
|
||||
): Promise<LocalTerminalSession> {
|
||||
const session = new LocalTerminalSession(options)
|
||||
await session.start()
|
||||
return session
|
||||
}
|
||||
|
||||
readonly sessionId: string
|
||||
private readonly options: LocalTerminalSessionOptions
|
||||
private readonly dependencies: LocalTerminalDependencies
|
||||
private readonly listeners = new Set<LocalTerminalEventListener>()
|
||||
private readonly pendingEvents: TerminalEvent[] = []
|
||||
private readonly subscriptions: IDisposable[] = []
|
||||
private pty: IPty | undefined
|
||||
private state: TerminalSnapshot['state'] = 'starting'
|
||||
private launch: LocalTerminalLaunch | undefined
|
||||
private size: TerminalSize
|
||||
private lastSequence = 0
|
||||
private pendingOutputBytes = 0
|
||||
private paused = false
|
||||
private exit: TerminalExit | null = null
|
||||
private error: TerminalError | null = null
|
||||
private closePromise: Promise<TerminalSnapshot> | undefined
|
||||
|
||||
private constructor(options: LocalTerminalSessionOptions) {
|
||||
this.options = options
|
||||
this.dependencies = options.dependencies ?? {}
|
||||
this.sessionId = options.sessionId ?? randomUUID()
|
||||
this.size = { ...options.size }
|
||||
this.emit({ type: 'state', state: 'starting' })
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
try {
|
||||
this.launch = await resolveLocalTerminalLaunch(
|
||||
this.options.target.type === 'project'
|
||||
? this.options.projectDirectory
|
||||
: undefined,
|
||||
this.dependencies
|
||||
)
|
||||
const spawn = this.dependencies.spawn ?? nodePty.spawn
|
||||
this.pty = spawn(this.launch.shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: this.size.cols,
|
||||
rows: this.size.rows,
|
||||
cwd: this.launch.cwd,
|
||||
env: this.launch.env
|
||||
})
|
||||
this.subscriptions.push(
|
||||
this.pty.onData((data) => this.handleOutput(data)),
|
||||
this.pty.onExit((result) =>
|
||||
this.handleExit(result.exitCode, result.signal)
|
||||
)
|
||||
)
|
||||
this.state = 'running'
|
||||
this.emit({ type: 'state', state: 'running' })
|
||||
} catch (cause) {
|
||||
this.fail(
|
||||
'launch-failed',
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'Failed to launch the local terminal'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(): TerminalSnapshot {
|
||||
const launch = this.launch
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
target: this.options.target,
|
||||
targetLabel: this.options.targetLabel,
|
||||
title: this.options.title,
|
||||
state: this.state,
|
||||
shell: launch?.shellLabel ?? '未知',
|
||||
workingDirectory:
|
||||
launch?.cwd ??
|
||||
this.options.projectDirectory ??
|
||||
(this.dependencies.homeDirectory ?? homedir)(),
|
||||
size: { ...this.size },
|
||||
lastSequence: this.lastSequence,
|
||||
exit: this.exit,
|
||||
error: this.error
|
||||
}
|
||||
}
|
||||
|
||||
onEvent(listener: LocalTerminalEventListener): () => void {
|
||||
this.listeners.add(listener)
|
||||
for (const event of this.pendingEvents) {
|
||||
listener(event)
|
||||
}
|
||||
return () => {
|
||||
this.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
write(data: string): boolean {
|
||||
if (this.state !== 'running' || !this.pty) {
|
||||
return false
|
||||
}
|
||||
this.pty.write(data)
|
||||
return true
|
||||
}
|
||||
|
||||
resize(size: TerminalSize): boolean {
|
||||
if (this.state !== 'running' || !this.pty) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
this.size.cols === size.cols &&
|
||||
this.size.rows === size.rows
|
||||
) {
|
||||
return true
|
||||
}
|
||||
this.pty.resize(size.cols, size.rows)
|
||||
this.size = { ...size }
|
||||
return true
|
||||
}
|
||||
|
||||
acknowledge(sequence: number): void {
|
||||
while (
|
||||
this.pendingEvents[0] &&
|
||||
this.pendingEvents[0].sequence <= sequence
|
||||
) {
|
||||
const event = this.pendingEvents.shift()
|
||||
if (event?.type === 'output') {
|
||||
this.pendingOutputBytes -= Buffer.byteLength(event.data)
|
||||
}
|
||||
}
|
||||
if (
|
||||
this.paused &&
|
||||
this.pendingEvents.length <= RESUME_PENDING_EVENT_COUNT &&
|
||||
this.pendingOutputBytes <=
|
||||
TERMINAL_LIMITS.maximumBufferedOutputBytes / 2
|
||||
) {
|
||||
this.paused = false
|
||||
this.pty?.resume()
|
||||
}
|
||||
}
|
||||
|
||||
close(): Promise<TerminalSnapshot> {
|
||||
if (this.closePromise) {
|
||||
return this.closePromise
|
||||
}
|
||||
this.closePromise = this.closeInternal()
|
||||
return this.closePromise
|
||||
}
|
||||
|
||||
private async closeInternal(): Promise<TerminalSnapshot> {
|
||||
if (
|
||||
this.state === 'exited' ||
|
||||
this.state === 'interrupted' ||
|
||||
(this.state === 'failed' && !this.pty)
|
||||
) {
|
||||
return this.snapshot()
|
||||
}
|
||||
const preserveFailedState = this.state === 'failed'
|
||||
if (!preserveFailedState) {
|
||||
this.state = 'closing'
|
||||
this.emit({ type: 'state', state: 'closing' })
|
||||
}
|
||||
const pty = this.pty
|
||||
if (pty) {
|
||||
try {
|
||||
await (
|
||||
this.dependencies.terminate ?? defaultTerminate
|
||||
)(pty, this.dependencies.platform ?? process.platform)
|
||||
} catch (cause) {
|
||||
this.error = {
|
||||
code: 'internal-error',
|
||||
message: this.boundErrorMessage(cause),
|
||||
retryable: false
|
||||
}
|
||||
this.emit({ type: 'error', error: this.error })
|
||||
} finally {
|
||||
if (preserveFailedState) {
|
||||
this.disposeSubscriptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.state === 'closing') {
|
||||
this.handleExit(null, 'SIGTERM')
|
||||
}
|
||||
return this.snapshot()
|
||||
}
|
||||
|
||||
private handleOutput(data: string): void {
|
||||
if (this.state !== 'running' || data.length === 0) {
|
||||
return
|
||||
}
|
||||
for (const chunk of splitBoundedOutput(data)) {
|
||||
if (
|
||||
this.pendingEvents.length >= PAUSE_PENDING_EVENT_COUNT ||
|
||||
this.pendingOutputBytes + Buffer.byteLength(chunk) >
|
||||
TERMINAL_LIMITS.maximumBufferedOutputBytes
|
||||
) {
|
||||
this.fail(
|
||||
'output-limit-exceeded',
|
||||
'Terminal output exceeded the bounded Main process buffer'
|
||||
)
|
||||
void this.close()
|
||||
return
|
||||
}
|
||||
this.pendingOutputBytes += Buffer.byteLength(chunk)
|
||||
this.emit({ type: 'output', data: chunk })
|
||||
this.updateBackpressure()
|
||||
}
|
||||
}
|
||||
|
||||
private updateBackpressure(): void {
|
||||
if (
|
||||
!this.paused &&
|
||||
(this.pendingEvents.length >= PAUSE_PENDING_EVENT_COUNT ||
|
||||
this.pendingOutputBytes >=
|
||||
TERMINAL_LIMITS.maximumBufferedOutputBytes)
|
||||
) {
|
||||
this.paused = true
|
||||
this.pty?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
private handleExit(
|
||||
exitCode: number | null,
|
||||
signal?: number | string
|
||||
): void {
|
||||
if (this.state === 'exited' || this.state === 'failed') {
|
||||
return
|
||||
}
|
||||
this.disposeSubscriptions()
|
||||
this.exit = {
|
||||
exitCode:
|
||||
typeof exitCode === 'number' &&
|
||||
Number.isSafeInteger(exitCode)
|
||||
? exitCode
|
||||
: null,
|
||||
signal:
|
||||
signal === undefined || signal === null
|
||||
? null
|
||||
: String(signal)
|
||||
}
|
||||
if (this.exit.exitCode === null && this.exit.signal === null) {
|
||||
this.exit.signal = 'unknown'
|
||||
}
|
||||
this.state = 'exited'
|
||||
this.emit({ type: 'exit', exit: this.exit })
|
||||
this.emit({ type: 'state', state: 'exited' })
|
||||
}
|
||||
|
||||
private fail(
|
||||
code: TerminalError['code'],
|
||||
message: string
|
||||
): void {
|
||||
if (this.state === 'failed' || this.state === 'exited') {
|
||||
return
|
||||
}
|
||||
this.error = {
|
||||
code,
|
||||
message: Buffer.from(message)
|
||||
.subarray(0, TERMINAL_LIMITS.maximumErrorMessageBytes)
|
||||
.toString('utf8') || 'Local terminal failed',
|
||||
retryable: code === 'launch-failed'
|
||||
}
|
||||
this.state = 'failed'
|
||||
this.emit({ type: 'error', error: this.error })
|
||||
this.emit({ type: 'state', state: 'failed' })
|
||||
}
|
||||
|
||||
private emit(
|
||||
event: LocalTerminalEvent
|
||||
): void {
|
||||
if (this.lastSequence >= Number.MAX_SAFE_INTEGER) {
|
||||
this.state = 'failed'
|
||||
this.error = {
|
||||
code: 'internal-error',
|
||||
message: 'Terminal event sequence was exhausted',
|
||||
retryable: false
|
||||
}
|
||||
return
|
||||
}
|
||||
const sequenced = {
|
||||
...event,
|
||||
sessionId: this.sessionId,
|
||||
sequence: ++this.lastSequence
|
||||
} as TerminalEvent
|
||||
this.pendingEvents.push(sequenced)
|
||||
for (const listener of this.listeners) {
|
||||
listener(sequenced)
|
||||
}
|
||||
}
|
||||
|
||||
private boundErrorMessage(cause: unknown): string {
|
||||
const message =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'Failed to close the local terminal'
|
||||
return (
|
||||
Buffer.from(message)
|
||||
.subarray(0, TERMINAL_LIMITS.maximumErrorMessageBytes)
|
||||
.toString('utf8') || 'Failed to close the local terminal'
|
||||
)
|
||||
}
|
||||
|
||||
private disposeSubscriptions(): void {
|
||||
for (const subscription of this.subscriptions.splice(0)) {
|
||||
subscription.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
AuthenticatedSshConnection,
|
||||
SshConnectionPoolTarget,
|
||||
SshPoolConnectionIdentity,
|
||||
SshTerminalShellOptions,
|
||||
} from "../ssh/ssh-connection-pool";
|
||||
import { SshConnectionPool } from "../ssh/ssh-connection-pool";
|
||||
import {
|
||||
createSshTerminalSession,
|
||||
sshTerminalInternals,
|
||||
} from "./ssh-terminal-session";
|
||||
|
||||
const sessionId = "00000000-0000-4000-8000-000000000501";
|
||||
const hostId = "00000000-0000-4000-8000-000000000502";
|
||||
const projectId = "00000000-0000-4000-8000-000000000503";
|
||||
|
||||
function createTarget(): SshConnectionPoolTarget {
|
||||
return {
|
||||
host: {
|
||||
id: hostId,
|
||||
name: "Build host",
|
||||
hostname: "build.example.com",
|
||||
port: 22,
|
||||
username: "builder",
|
||||
authentication: "password",
|
||||
password: "private password",
|
||||
hostKey: {
|
||||
algorithm: "ssh-ed25519",
|
||||
publicKeyBase64: "pinned",
|
||||
fingerprintSha256: `SHA256:${"A".repeat(43)}`,
|
||||
generation: 3,
|
||||
},
|
||||
},
|
||||
hostRevision: 7,
|
||||
hostKeyGeneration: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function createChannel() {
|
||||
const emitter = new EventEmitter();
|
||||
return Object.assign(emitter, {
|
||||
write: vi.fn(() => true),
|
||||
setWindow: vi.fn(),
|
||||
pause: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
close: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
function createHarness(idleTimeoutMs = 60_000) {
|
||||
const channel = createChannel();
|
||||
let usable = true;
|
||||
const disconnectListeners = new Set<(error?: Error) => void>();
|
||||
let shellOptions: SshTerminalShellOptions | undefined;
|
||||
const identity: SshPoolConnectionIdentity = {
|
||||
hostId,
|
||||
hostRevision: 7,
|
||||
hostKeyGeneration: 3,
|
||||
authenticationIdentity: "a".repeat(64),
|
||||
};
|
||||
const connection = {
|
||||
identity,
|
||||
isUsable: vi.fn(() => usable),
|
||||
onDisconnect: vi.fn((listener: (error?: Error) => void) => {
|
||||
disconnectListeners.add(listener);
|
||||
return () => disconnectListeners.delete(listener);
|
||||
}),
|
||||
openTerminalShell: vi.fn(
|
||||
async (options: SshTerminalShellOptions) => {
|
||||
shellOptions = options;
|
||||
return channel as never;
|
||||
},
|
||||
),
|
||||
openAgentAttach: vi.fn(),
|
||||
runAgentDoctor: vi.fn(),
|
||||
runAgentBootstrapProbe: vi.fn(),
|
||||
probeRemotePackageBootstrap: vi.fn(),
|
||||
createRemotePackageUploadStaging: vi.fn(),
|
||||
prepareRemotePackageBootstrap: vi.fn(),
|
||||
prepareUploadedRemotePackageBootstrap: vi.fn(),
|
||||
commitRemotePackageBootstrap: vi.fn(),
|
||||
cleanupRemotePackageBootstrap: vi.fn(),
|
||||
runAgentLifecycleAction: vi.fn(),
|
||||
runAgentRuntimeAction: vi.fn(),
|
||||
openStagedSftp: vi.fn(),
|
||||
listDirectories: vi.fn(),
|
||||
dispose: vi.fn(() => {
|
||||
usable = false;
|
||||
}),
|
||||
} satisfies AuthenticatedSshConnection;
|
||||
const pool = new SshConnectionPool(async () => connection, {
|
||||
idleTimeoutMs,
|
||||
});
|
||||
const events: Array<{
|
||||
type: string;
|
||||
sequence: number;
|
||||
[key: string]: unknown;
|
||||
}> = [];
|
||||
|
||||
return {
|
||||
channel,
|
||||
connection,
|
||||
pool,
|
||||
events,
|
||||
shellOptions: () => shellOptions,
|
||||
disconnect(error?: Error) {
|
||||
usable = false;
|
||||
for (const listener of [...disconnectListeners]) {
|
||||
listener(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openSession(harness: ReturnType<typeof createHarness>) {
|
||||
return createSshTerminalSession(harness.pool, {
|
||||
sessionId,
|
||||
target: { type: "project", projectId },
|
||||
targetLabel: "Build host",
|
||||
title: "终端 · Build host",
|
||||
shell: "/bin/bash",
|
||||
workingDirectory: "/srv/team's app",
|
||||
size: { cols: 120, rows: 40 },
|
||||
poolTarget: createTarget(),
|
||||
onEvent: (event) => harness.events.push(event),
|
||||
});
|
||||
}
|
||||
|
||||
describe("SSH terminal session", () => {
|
||||
it("holds a pool lease and opens a login shell with the requested PTY and quoted cwd", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const harness = createHarness(100);
|
||||
const session = await openSession(harness);
|
||||
|
||||
expect(harness.shellOptions()).toEqual({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
term: "xterm-256color",
|
||||
});
|
||||
expect(harness.channel.write).toHaveBeenCalledWith(
|
||||
"cd -- '/srv/team'\"'\"'s app' || exit\r",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(harness.connection.dispose).not.toHaveBeenCalled();
|
||||
|
||||
expect(session.close()).toMatchObject({
|
||||
state: "exited",
|
||||
exit: { exitCode: null, signal: "SIGHUP" },
|
||||
});
|
||||
expect(session.close()).toMatchObject({ state: "exited" });
|
||||
expect(harness.channel.close).toHaveBeenCalledOnce();
|
||||
expect(harness.channel.destroy).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(harness.connection.dispose).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards writes and resizes while running and returns an authoritative snapshot", async () => {
|
||||
const harness = createHarness();
|
||||
const session = await openSession(harness);
|
||||
|
||||
session.write("printf '你好'\\r");
|
||||
session.resize({ cols: 90, rows: 28 });
|
||||
|
||||
expect(harness.channel.write).toHaveBeenLastCalledWith(
|
||||
"printf '你好'\\r",
|
||||
);
|
||||
expect(harness.channel.setWindow).toHaveBeenCalledWith(28, 90, 0, 0);
|
||||
expect(session.snapshot()).toMatchObject({
|
||||
sessionId,
|
||||
state: "running",
|
||||
shell: "/bin/bash",
|
||||
workingDirectory: "/srv/team's app",
|
||||
size: { cols: 90, rows: 28 },
|
||||
lastSequence: 1,
|
||||
});
|
||||
session.close();
|
||||
});
|
||||
|
||||
it("emits ordered bounded output and pauses until acknowledged", async () => {
|
||||
const harness = createHarness();
|
||||
const session = await openSession(harness);
|
||||
|
||||
for (let index = 0; index < 255; index += 1) {
|
||||
harness.channel.emit("data", `chunk-${index}`);
|
||||
}
|
||||
|
||||
expect(harness.events).toHaveLength(256);
|
||||
expect(harness.events.map((event) => event.sequence)).toEqual(
|
||||
Array.from({ length: 256 }, (_, index) => index + 1),
|
||||
);
|
||||
expect(harness.channel.pause).toHaveBeenCalledOnce();
|
||||
session.acknowledge(256);
|
||||
expect(harness.channel.resume).toHaveBeenCalledOnce();
|
||||
session.close();
|
||||
});
|
||||
|
||||
it("marks a disconnected SSH transport interrupted immediately and rejects late I/O", async () => {
|
||||
const harness = createHarness();
|
||||
const session = await openSession(harness);
|
||||
harness.disconnect(new Error("socket reset"));
|
||||
|
||||
expect(session.snapshot()).toMatchObject({
|
||||
state: "interrupted",
|
||||
error: {
|
||||
code: "interrupted",
|
||||
message: "socket reset",
|
||||
retryable: true,
|
||||
},
|
||||
});
|
||||
expect(harness.events.at(-1)).toMatchObject({
|
||||
type: "state",
|
||||
state: "interrupted",
|
||||
});
|
||||
const writes = harness.channel.write.mock.calls.length;
|
||||
session.write("late");
|
||||
session.resize({ cols: 80, rows: 24 });
|
||||
expect(harness.channel.write).toHaveBeenCalledTimes(writes);
|
||||
expect(harness.channel.setWindow).not.toHaveBeenCalled();
|
||||
session.close();
|
||||
});
|
||||
|
||||
it("records normal shell exit before releasing the channel lease", async () => {
|
||||
const harness = createHarness();
|
||||
const session = await openSession(harness);
|
||||
harness.channel.emit("exit", 7);
|
||||
harness.channel.emit("close");
|
||||
|
||||
expect(session.snapshot()).toMatchObject({
|
||||
state: "exited",
|
||||
exit: { exitCode: 7, signal: null },
|
||||
});
|
||||
expect(harness.events.slice(-2)).toMatchObject([
|
||||
{ type: "exit", exit: { exitCode: 7, signal: null } },
|
||||
{ type: "state", state: "exited" },
|
||||
]);
|
||||
session.close();
|
||||
});
|
||||
|
||||
it("strictly quotes POSIX paths and rejects line-based command injection", () => {
|
||||
expect(sshTerminalInternals.quotePosixShellArgument("a'b")).toBe(
|
||||
"'a'\"'\"'b'",
|
||||
);
|
||||
expect(() =>
|
||||
sshTerminalInternals.quotePosixShellArgument("/srv/app\nwhoami"),
|
||||
).toThrow("无效字符");
|
||||
});
|
||||
|
||||
it("opens a Host-home shell without injecting a cwd command", async () => {
|
||||
const harness = createHarness();
|
||||
const session = await createSshTerminalSession(harness.pool, {
|
||||
sessionId,
|
||||
target: { type: "project", projectId },
|
||||
targetLabel: "Build host",
|
||||
title: "终端 · Build host",
|
||||
size: { cols: 80, rows: 24 },
|
||||
poolTarget: createTarget(),
|
||||
onEvent: (event) => harness.events.push(event),
|
||||
});
|
||||
|
||||
expect(harness.channel.write).not.toHaveBeenCalled();
|
||||
expect(session.snapshot().workingDirectory).toBe("~");
|
||||
session.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type { ClientChannel } from "ssh2";
|
||||
import {
|
||||
TERMINAL_LIMITS,
|
||||
terminalSizeSchema,
|
||||
type TerminalError,
|
||||
type TerminalEvent,
|
||||
type TerminalExit,
|
||||
type TerminalSessionId,
|
||||
type TerminalSize,
|
||||
type TerminalSnapshot,
|
||||
type TerminalTarget,
|
||||
} from "../../shared/terminal-contracts";
|
||||
import {
|
||||
type SshConnectionPool,
|
||||
type SshConnectionPoolTarget,
|
||||
type SshTerminalConnectionLease,
|
||||
} from "../ssh/ssh-connection-pool";
|
||||
|
||||
const SSH_TERMINAL_TERM = "xterm-256color";
|
||||
|
||||
export type SshTerminalSessionOptions = {
|
||||
sessionId: TerminalSessionId;
|
||||
target: TerminalTarget;
|
||||
targetLabel: string;
|
||||
title: string;
|
||||
shell?: string;
|
||||
workingDirectory?: string;
|
||||
size: TerminalSize;
|
||||
poolTarget: SshConnectionPoolTarget;
|
||||
onEvent: (event: TerminalEvent) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type SshTerminalSession = {
|
||||
write(data: string): void;
|
||||
resize(size: TerminalSize): void;
|
||||
acknowledge(sequence: number): void;
|
||||
snapshot(): TerminalSnapshot;
|
||||
close(): TerminalSnapshot;
|
||||
};
|
||||
|
||||
type RunningSession = {
|
||||
lease: SshTerminalConnectionLease;
|
||||
channel: ClientChannel;
|
||||
removeDisconnectListener: () => void;
|
||||
};
|
||||
|
||||
type TerminalEventPayload =
|
||||
| { type: "output"; data: string }
|
||||
| { type: "state"; state: TerminalSnapshot["state"] }
|
||||
| { type: "exit"; exit: TerminalExit }
|
||||
| { type: "error"; error: TerminalError };
|
||||
|
||||
function quotePosixShellArgument(value: string): string {
|
||||
if (value.includes("\0") || value.includes("\r") || value.includes("\n")) {
|
||||
throw new Error("远程工作目录包含无效字符");
|
||||
}
|
||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function eventByteLength(event: TerminalEvent): number {
|
||||
switch (event.type) {
|
||||
case "output":
|
||||
return Buffer.byteLength(event.data);
|
||||
case "error":
|
||||
return Buffer.byteLength(event.error.message) + 64;
|
||||
default:
|
||||
return 64;
|
||||
}
|
||||
}
|
||||
|
||||
function splitUtf8(text: string, maximumBytes: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
let chunk = "";
|
||||
let chunkBytes = 0;
|
||||
for (const character of text) {
|
||||
const characterBytes = Buffer.byteLength(character);
|
||||
if (chunk && chunkBytes + characterBytes > maximumBytes) {
|
||||
chunks.push(chunk);
|
||||
chunk = "";
|
||||
chunkBytes = 0;
|
||||
}
|
||||
chunk += character;
|
||||
chunkBytes += characterBytes;
|
||||
}
|
||||
if (chunk) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export async function createSshTerminalSession(
|
||||
pool: SshConnectionPool,
|
||||
options: SshTerminalSessionOptions,
|
||||
): Promise<SshTerminalSession> {
|
||||
const initialSize = terminalSizeSchema.parse(options.size);
|
||||
const workingDirectoryCommand = options.workingDirectory
|
||||
? `cd -- ${quotePosixShellArgument(options.workingDirectory)} || exit\r`
|
||||
: undefined;
|
||||
const lease = await pool.acquireTerminal(options.poolTarget, options.signal);
|
||||
let channel: ClientChannel;
|
||||
try {
|
||||
channel = await lease.openTerminalShell(
|
||||
{
|
||||
cols: initialSize.cols,
|
||||
rows: initialSize.rows,
|
||||
term: SSH_TERMINAL_TERM,
|
||||
},
|
||||
options.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
lease.release();
|
||||
throw error;
|
||||
}
|
||||
|
||||
let state: TerminalSnapshot["state"] = "running";
|
||||
let size = initialSize;
|
||||
let sequence = 0;
|
||||
let exit: TerminalExit | null = null;
|
||||
let error: TerminalError | null = null;
|
||||
let closed = false;
|
||||
let paused = false;
|
||||
let pendingBytes = 0;
|
||||
const pendingEvents: Array<{ event: TerminalEvent; bytes: number }> = [];
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let running: RunningSession | undefined;
|
||||
|
||||
const emit = (event: TerminalEventPayload): void => {
|
||||
sequence += 1;
|
||||
const sequenced = {
|
||||
...event,
|
||||
sessionId: options.sessionId,
|
||||
sequence,
|
||||
} as TerminalEvent;
|
||||
const bytes = eventByteLength(sequenced);
|
||||
pendingEvents.push({ event: sequenced, bytes });
|
||||
pendingBytes += bytes;
|
||||
options.onEvent(sequenced);
|
||||
if (
|
||||
!paused &&
|
||||
(pendingEvents.length >= TERMINAL_LIMITS.maximumPendingEvents ||
|
||||
pendingBytes >=
|
||||
TERMINAL_LIMITS.maximumBufferedOutputBytes -
|
||||
TERMINAL_LIMITS.maximumEventBytes)
|
||||
) {
|
||||
paused = true;
|
||||
channel.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const release = (): void => {
|
||||
const active = running;
|
||||
running = undefined;
|
||||
active?.removeDisconnectListener();
|
||||
active?.lease.release();
|
||||
};
|
||||
|
||||
const interrupt = (disconnectError?: Error): void => {
|
||||
if (closed || state !== "running") {
|
||||
return;
|
||||
}
|
||||
state = "interrupted";
|
||||
error = {
|
||||
code: "interrupted",
|
||||
message: disconnectError?.message || "SSH 连接已中断",
|
||||
retryable: true,
|
||||
};
|
||||
emit({ type: "error", error });
|
||||
emit({ type: "state", state });
|
||||
release();
|
||||
channel.destroy();
|
||||
};
|
||||
|
||||
const removeDisconnectListener = lease.onDisconnect(interrupt);
|
||||
running = { lease, channel, removeDisconnectListener };
|
||||
|
||||
channel.on("data", (chunk: Buffer | string) => {
|
||||
if (closed || state !== "running") {
|
||||
return;
|
||||
}
|
||||
const text =
|
||||
typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk));
|
||||
for (const data of splitUtf8(
|
||||
text,
|
||||
TERMINAL_LIMITS.maximumEventBytes,
|
||||
)) {
|
||||
emit({ type: "output", data });
|
||||
}
|
||||
});
|
||||
channel.once(
|
||||
"exit",
|
||||
(code: number | null, signal?: string) => {
|
||||
if (closed || state !== "running") {
|
||||
return;
|
||||
}
|
||||
exit =
|
||||
typeof code === "number"
|
||||
? { exitCode: code, signal: null }
|
||||
: { exitCode: null, signal: signal || "UNKNOWN" };
|
||||
},
|
||||
);
|
||||
channel.once("error", (channelError: Error) => {
|
||||
interrupt(channelError);
|
||||
});
|
||||
channel.once("close", () => {
|
||||
if (closed || state !== "running") {
|
||||
release();
|
||||
return;
|
||||
}
|
||||
const finalText = decoder.end();
|
||||
if (finalText) {
|
||||
for (const data of splitUtf8(
|
||||
finalText,
|
||||
TERMINAL_LIMITS.maximumEventBytes,
|
||||
)) {
|
||||
emit({ type: "output", data });
|
||||
}
|
||||
}
|
||||
if (!lease.isUsable()) {
|
||||
interrupt();
|
||||
return;
|
||||
}
|
||||
state = "exited";
|
||||
exit ??= { exitCode: 0, signal: null };
|
||||
emit({ type: "exit", exit });
|
||||
emit({ type: "state", state });
|
||||
release();
|
||||
});
|
||||
|
||||
if (workingDirectoryCommand) {
|
||||
channel.write(workingDirectoryCommand);
|
||||
}
|
||||
emit({ type: "state", state });
|
||||
|
||||
const currentSnapshot = (): TerminalSnapshot => ({
|
||||
sessionId: options.sessionId,
|
||||
target: options.target,
|
||||
targetLabel: options.targetLabel,
|
||||
title: options.title,
|
||||
state,
|
||||
shell: options.shell ?? "login shell",
|
||||
workingDirectory: options.workingDirectory || "~",
|
||||
size,
|
||||
lastSequence: sequence,
|
||||
exit,
|
||||
error,
|
||||
});
|
||||
|
||||
return {
|
||||
write(data) {
|
||||
if (closed || state !== "running") {
|
||||
return;
|
||||
}
|
||||
channel.write(data);
|
||||
},
|
||||
resize(nextSize) {
|
||||
if (closed || state !== "running") {
|
||||
return;
|
||||
}
|
||||
size = terminalSizeSchema.parse(nextSize);
|
||||
channel.setWindow(size.rows, size.cols, 0, 0);
|
||||
},
|
||||
acknowledge(acknowledgedSequence) {
|
||||
if (
|
||||
!Number.isSafeInteger(acknowledgedSequence) ||
|
||||
acknowledgedSequence < 0
|
||||
) {
|
||||
throw new Error("终端输出确认序号无效");
|
||||
}
|
||||
while (
|
||||
pendingEvents[0] &&
|
||||
pendingEvents[0].event.sequence <= acknowledgedSequence
|
||||
) {
|
||||
pendingBytes -= pendingEvents.shift()?.bytes ?? 0;
|
||||
}
|
||||
if (
|
||||
paused &&
|
||||
pendingEvents.length < TERMINAL_LIMITS.maximumPendingEvents &&
|
||||
pendingBytes <
|
||||
TERMINAL_LIMITS.maximumBufferedOutputBytes -
|
||||
TERMINAL_LIMITS.maximumEventBytes
|
||||
) {
|
||||
paused = false;
|
||||
channel.resume();
|
||||
}
|
||||
},
|
||||
snapshot() {
|
||||
return currentSnapshot();
|
||||
},
|
||||
close() {
|
||||
if (closed) {
|
||||
return currentSnapshot();
|
||||
}
|
||||
closed = true;
|
||||
if (state === "running") {
|
||||
state = "closing";
|
||||
emit({ type: "state", state });
|
||||
exit = { exitCode: null, signal: "SIGHUP" };
|
||||
}
|
||||
release();
|
||||
channel.close();
|
||||
channel.destroy();
|
||||
if (state === "closing") {
|
||||
state = "exited";
|
||||
emit({ type: "exit", exit: exit! });
|
||||
emit({ type: "state", state });
|
||||
}
|
||||
return currentSnapshot();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const sshTerminalInternals = {
|
||||
quotePosixShellArgument,
|
||||
};
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TERMINAL_LIMITS,
|
||||
type TerminalEvent,
|
||||
type TerminalSnapshot,
|
||||
type TerminalTarget
|
||||
} from '../../shared/terminal-contracts'
|
||||
import {
|
||||
TerminalSessionManager,
|
||||
TerminalSessionManagerError,
|
||||
type ManagedTerminalSession,
|
||||
type TerminalSessionManagerDependencies
|
||||
} from './terminal-session-manager'
|
||||
|
||||
const projectId = '00000000-0000-4000-8000-000000000601'
|
||||
const hostId = '00000000-0000-4000-8000-000000000602'
|
||||
|
||||
function snapshot(
|
||||
sessionId: string,
|
||||
target: TerminalTarget,
|
||||
workingDirectory = '/home/tester'
|
||||
): TerminalSnapshot {
|
||||
return {
|
||||
sessionId,
|
||||
target,
|
||||
targetLabel: target.type === 'local' ? '本机' : 'Build host',
|
||||
title: '终端',
|
||||
state: 'running',
|
||||
shell: 'bash',
|
||||
workingDirectory,
|
||||
size: { cols: 80, rows: 24 },
|
||||
lastSequence: 1,
|
||||
exit: null,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
|
||||
function fakeSession(
|
||||
value: TerminalSnapshot,
|
||||
initialEvents: TerminalEvent[] = []
|
||||
): ManagedTerminalSession & {
|
||||
emit(event: TerminalEvent): void
|
||||
close: ReturnType<typeof vi.fn>
|
||||
write: ReturnType<typeof vi.fn>
|
||||
resize: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
let current = value
|
||||
const listeners = new Set<(event: TerminalEvent) => void>()
|
||||
return {
|
||||
snapshot: () => current,
|
||||
write: vi.fn(() => true),
|
||||
resize: vi.fn(() => true),
|
||||
acknowledge: vi.fn(),
|
||||
close: vi.fn(async () => {
|
||||
current = { ...current, state: 'closing' }
|
||||
return current
|
||||
}),
|
||||
onEvent(listener) {
|
||||
listeners.add(listener)
|
||||
initialEvents.forEach(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
emit(event) {
|
||||
for (const listener of listeners) {
|
||||
listener(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function harness(
|
||||
overrides: Partial<TerminalSessionManagerDependencies> = {}
|
||||
) {
|
||||
let nextId = 700
|
||||
const delivered: Array<{ ownerId: number; event: TerminalEvent }> = []
|
||||
const localSessions: ReturnType<typeof fakeSession>[] = []
|
||||
const sshSessions: ReturnType<typeof fakeSession>[] = []
|
||||
const createLocalSession = vi.fn(async (options) => {
|
||||
const event = {
|
||||
sessionId: options.sessionId!,
|
||||
sequence: 1,
|
||||
type: 'output' as const,
|
||||
data: 'prompt'
|
||||
}
|
||||
const session = fakeSession(
|
||||
snapshot(
|
||||
options.sessionId!,
|
||||
options.target,
|
||||
options.projectDirectory ?? '/home/tester'
|
||||
),
|
||||
[event]
|
||||
)
|
||||
localSessions.push(session)
|
||||
return session
|
||||
})
|
||||
const createSshSession = vi.fn(async (_pool, options) => {
|
||||
options.onEvent({
|
||||
sessionId: options.sessionId,
|
||||
sequence: 1,
|
||||
type: 'output',
|
||||
data: 'remote prompt'
|
||||
})
|
||||
const session = fakeSession(
|
||||
snapshot(
|
||||
options.sessionId,
|
||||
options.target,
|
||||
options.workingDirectory
|
||||
)
|
||||
)
|
||||
sshSessions.push(session)
|
||||
return session
|
||||
})
|
||||
const dependencies = {
|
||||
database: {
|
||||
getProject: vi.fn(() => ({
|
||||
id: projectId,
|
||||
name: 'Workspace',
|
||||
rootPath: 'legacy',
|
||||
executionSpace: { kind: 'local', rootPath: '/authoritative/root' }
|
||||
}))
|
||||
},
|
||||
executionSpaceResolver: {
|
||||
resolveProject: vi.fn((project: {
|
||||
executionSpace: { kind: 'local'; rootPath: string }
|
||||
}) => ({
|
||||
kind: 'local' as const,
|
||||
rootPath: project.executionSpace.rootPath,
|
||||
cacheIdentity: 'local',
|
||||
routeIdentity: 'local',
|
||||
workspaceAccess: {}
|
||||
}))
|
||||
},
|
||||
targetResolver: {
|
||||
resolve: vi.fn(async () => ({
|
||||
host: { id: hostId, name: 'Build host' },
|
||||
hostRevision: 2,
|
||||
hostKeyGeneration: 3
|
||||
}))
|
||||
},
|
||||
sshPool: {} as never,
|
||||
remoteEnabled: vi.fn(() => true),
|
||||
deliverEvent: vi.fn((ownerId, event) => {
|
||||
delivered.push({ ownerId, event })
|
||||
}),
|
||||
createLocalSession,
|
||||
createSshSession,
|
||||
createSessionId: () =>
|
||||
`00000000-0000-4000-8000-${String(nextId++).padStart(12, '0')}`,
|
||||
...overrides
|
||||
} as unknown as TerminalSessionManagerDependencies
|
||||
return {
|
||||
manager: new TerminalSessionManager(dependencies),
|
||||
dependencies,
|
||||
delivered,
|
||||
localSessions,
|
||||
sshSessions,
|
||||
createLocalSession,
|
||||
createSshSession
|
||||
}
|
||||
}
|
||||
|
||||
describe('TerminalSessionManager routing and delivery', () => {
|
||||
it('routes Home local and authoritative local project roots', async () => {
|
||||
const test = harness()
|
||||
const local = await test.manager.create(1, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
const project = await test.manager.create(1, {
|
||||
target: { type: 'project', projectId },
|
||||
cols: 90,
|
||||
rows: 30
|
||||
})
|
||||
|
||||
expect(local.workingDirectory).toBe('/home/tester')
|
||||
expect(project.workingDirectory).toBe('/authoritative/root')
|
||||
expect(test.createLocalSession.mock.calls[0]?.[0].projectDirectory)
|
||||
.toBeUndefined()
|
||||
expect(test.createLocalSession.mock.calls[1]?.[0].projectDirectory)
|
||||
.toBe('/authoritative/root')
|
||||
})
|
||||
|
||||
it('routes SSH projects to their authoritative remote root', async () => {
|
||||
const test = harness()
|
||||
vi.mocked(test.dependencies.executionSpaceResolver.resolveProject)
|
||||
.mockReturnValue({
|
||||
kind: 'ssh',
|
||||
hostId,
|
||||
remoteRootPath: '/srv/project',
|
||||
cacheIdentity: 'ssh',
|
||||
routeIdentity: 'ssh',
|
||||
workspaceAccess: {} as never
|
||||
})
|
||||
|
||||
const remote = await test.manager.create(1, {
|
||||
target: { type: 'project', projectId },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(test.createSshSession.mock.calls[0]?.[1].workingDirectory)
|
||||
.toBe('/srv/project')
|
||||
expect(remote.workingDirectory).toBe('/srv/project')
|
||||
expect(test.dependencies.targetResolver.resolve).toHaveBeenCalledWith(hostId)
|
||||
})
|
||||
|
||||
it('gates all remote targets', async () => {
|
||||
const test = harness({ remoteEnabled: () => false })
|
||||
vi.mocked(test.dependencies.executionSpaceResolver.resolveProject)
|
||||
.mockReturnValue({
|
||||
kind: 'ssh',
|
||||
hostId,
|
||||
remoteRootPath: '/srv/project',
|
||||
cacheIdentity: 'ssh',
|
||||
routeIdentity: 'ssh',
|
||||
workspaceAccess: {} as never
|
||||
})
|
||||
await expect(test.manager.create(1, {
|
||||
target: { type: 'project', projectId },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})).rejects.toMatchObject({ code: 'target-unavailable' })
|
||||
expect(test.createSshSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delays startup events until delivery is explicitly enabled', async () => {
|
||||
const test = harness()
|
||||
const created = await test.manager.create(7, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
expect(test.delivered).toEqual([])
|
||||
|
||||
test.manager.enableEventDelivery(7, created.sessionId)
|
||||
expect(test.delivered).toEqual([
|
||||
{
|
||||
ownerId: 7,
|
||||
event: expect.objectContaining({ data: 'prompt', sequence: 1 })
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('TerminalSessionManager ownership and lifecycle', () => {
|
||||
it('revalidates ownership for every session operation', async () => {
|
||||
const test = harness()
|
||||
const created = await test.manager.create(11, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
const calls = [
|
||||
() => test.manager.write(12, created.sessionId, 'x'),
|
||||
() => test.manager.resize(12, created.sessionId, { cols: 90, rows: 30 }),
|
||||
() => test.manager.snapshot(12, created.sessionId),
|
||||
() => test.manager.acknowledge(12, created.sessionId, 1),
|
||||
() => test.manager.close(12, created.sessionId),
|
||||
() => test.manager.enableEventDelivery(12, created.sessionId)
|
||||
]
|
||||
for (const call of calls) {
|
||||
expect(call).toThrowError(
|
||||
expect.objectContaining({ code: 'session-not-found' })
|
||||
)
|
||||
}
|
||||
expect(test.localSessions[0]?.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enforces the per-window limit without affecting another owner', async () => {
|
||||
const test = harness()
|
||||
for (let index = 0;
|
||||
index < TERMINAL_LIMITS.maximumSessionsPerWindow;
|
||||
index += 1) {
|
||||
await test.manager.create(1, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
}
|
||||
await expect(test.manager.create(1, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})).rejects.toBeInstanceOf(TerminalSessionManagerError)
|
||||
await expect(test.manager.create(2, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('releases closed records and ignores late delivery ACKs', async () => {
|
||||
const test = harness()
|
||||
const created = await test.manager.create(1, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
const first = await test.manager.close(1, created.sessionId)
|
||||
|
||||
expect(first.state).toBe('closing')
|
||||
expect(test.localSessions[0]?.close).toHaveBeenCalledTimes(1)
|
||||
expect(() =>
|
||||
test.manager.acknowledge(1, created.sessionId, 1)
|
||||
).not.toThrow()
|
||||
expect(() => test.manager.close(1, created.sessionId))
|
||||
.toThrowError(expect.objectContaining({ code: 'session-not-found' }))
|
||||
expect(() => test.manager.write(1, created.sessionId, 'late'))
|
||||
.toThrowError(expect.objectContaining({ code: 'session-not-found' }))
|
||||
expect(() => test.manager.resize(
|
||||
1,
|
||||
created.sessionId,
|
||||
{ cols: 100, rows: 40 }
|
||||
)).toThrowError(expect.objectContaining({
|
||||
code: 'session-not-found'
|
||||
}))
|
||||
|
||||
await expect(test.manager.create(1, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('closes only one owner and disposes all sessions idempotently', async () => {
|
||||
const test = harness()
|
||||
const first = await test.manager.create(1, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
const second = await test.manager.create(2, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
await test.manager.closeOwner(1)
|
||||
expect(() => test.manager.snapshot(1, first.sessionId))
|
||||
.toThrowError(expect.objectContaining({ code: 'session-not-found' }))
|
||||
expect(test.manager.snapshot(2, second.sessionId).state).toBe('running')
|
||||
|
||||
await Promise.all([test.manager.dispose(), test.manager.dispose()])
|
||||
expect(test.localSessions[0]?.close).toHaveBeenCalledTimes(1)
|
||||
expect(test.localSessions[1]?.close).toHaveBeenCalledTimes(1)
|
||||
await expect(test.manager.create(2, {
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})).rejects.toMatchObject({ code: 'internal-error' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,479 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
TERMINAL_LIMITS,
|
||||
type TerminalCreateRequest,
|
||||
type TerminalErrorCode,
|
||||
type TerminalEvent,
|
||||
type TerminalSize,
|
||||
type TerminalSnapshot
|
||||
} from '../../shared/terminal-contracts'
|
||||
import type { AssistantDatabase } from '../assistant/assistant-database'
|
||||
import {
|
||||
type ExecutionSpaceDescriptor,
|
||||
type ExecutionSpaceResolver
|
||||
} from '../execution-space/execution-space-resolver'
|
||||
import type { RemoteAgentTargetResolver } from '../remote-agent/remote-agent-connection-manager'
|
||||
import type {
|
||||
SshConnectionPool,
|
||||
SshConnectionPoolTarget
|
||||
} from '../ssh/ssh-connection-pool'
|
||||
import {
|
||||
LocalTerminalSession,
|
||||
type LocalTerminalSessionOptions
|
||||
} from './local-terminal-session'
|
||||
import {
|
||||
createSshTerminalSession,
|
||||
type SshTerminalSession,
|
||||
type SshTerminalSessionOptions
|
||||
} from './ssh-terminal-session'
|
||||
|
||||
export class TerminalSessionManagerError extends Error {
|
||||
constructor(
|
||||
readonly code: TerminalErrorCode,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'TerminalSessionManagerError'
|
||||
}
|
||||
}
|
||||
|
||||
export type ManagedTerminalSession = {
|
||||
snapshot(): TerminalSnapshot
|
||||
write(data: string): boolean | void
|
||||
resize(size: TerminalSize): boolean | void
|
||||
acknowledge(sequence: number): void
|
||||
close(): void | TerminalSnapshot | Promise<void | TerminalSnapshot>
|
||||
onEvent?: (listener: (event: TerminalEvent) => void) => () => void
|
||||
}
|
||||
|
||||
export type TerminalSessionManagerDependencies = {
|
||||
database: Pick<AssistantDatabase, 'getProject'>
|
||||
executionSpaceResolver: Pick<ExecutionSpaceResolver, 'resolveProject'>
|
||||
targetResolver: RemoteAgentTargetResolver
|
||||
sshPool: SshConnectionPool
|
||||
remoteEnabled: () => boolean | Promise<boolean>
|
||||
deliverEvent: (ownerWebContentsId: number, event: TerminalEvent) => void
|
||||
createLocalSession?: (
|
||||
options: LocalTerminalSessionOptions
|
||||
) => Promise<ManagedTerminalSession>
|
||||
createSshSession?: (
|
||||
pool: SshConnectionPool,
|
||||
options: SshTerminalSessionOptions
|
||||
) => Promise<ManagedTerminalSession>
|
||||
createSessionId?: () => string
|
||||
}
|
||||
|
||||
type SessionRecord = {
|
||||
ownerId: number
|
||||
sessionId: string
|
||||
deliveryEnabled: boolean
|
||||
delayedEvents: TerminalEvent[]
|
||||
closing: boolean
|
||||
session?: ManagedTerminalSession
|
||||
ready: Promise<ManagedTerminalSession>
|
||||
closePromise?: Promise<TerminalSnapshot>
|
||||
removeEventListener?: () => void
|
||||
}
|
||||
|
||||
type ResolvedLaunch =
|
||||
| {
|
||||
kind: 'local'
|
||||
targetLabel: string
|
||||
projectDirectory?: string
|
||||
}
|
||||
| {
|
||||
kind: 'ssh'
|
||||
targetLabel: string
|
||||
workingDirectory: string
|
||||
poolTarget: SshConnectionPoolTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns ephemeral terminal sessions. Session identifiers are generated in Main
|
||||
* and every operation is scoped to the creating WebContents identifier.
|
||||
*/
|
||||
export class TerminalSessionManager {
|
||||
private readonly records = new Map<string, SessionRecord>()
|
||||
private disposePromise: Promise<void> | undefined
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: TerminalSessionManagerDependencies
|
||||
) {}
|
||||
|
||||
async create(
|
||||
ownerWebContentsId: number,
|
||||
request: TerminalCreateRequest
|
||||
): Promise<TerminalSnapshot> {
|
||||
this.assertOwnerId(ownerWebContentsId)
|
||||
if (this.disposed) {
|
||||
throw new TerminalSessionManagerError(
|
||||
'internal-error',
|
||||
'终端会话管理器已关闭'
|
||||
)
|
||||
}
|
||||
if (this.ownerSessionCount(ownerWebContentsId) >=
|
||||
TERMINAL_LIMITS.maximumSessionsPerWindow) {
|
||||
throw new TerminalSessionManagerError(
|
||||
'session-limit-reached',
|
||||
`每个窗口最多可同时打开 ${TERMINAL_LIMITS.maximumSessionsPerWindow} 个终端`
|
||||
)
|
||||
}
|
||||
|
||||
const sessionId =
|
||||
this.dependencies.createSessionId?.() ?? randomUUID()
|
||||
const record: SessionRecord = {
|
||||
ownerId: ownerWebContentsId,
|
||||
sessionId,
|
||||
deliveryEnabled: false,
|
||||
delayedEvents: [],
|
||||
closing: false,
|
||||
ready: Promise.resolve(undefined as never)
|
||||
}
|
||||
this.records.set(sessionId, record)
|
||||
record.ready = this.createSession(record, request)
|
||||
|
||||
try {
|
||||
const session = await record.ready
|
||||
if (record.closing) {
|
||||
return await this.closeRecord(record)
|
||||
}
|
||||
return session.snapshot()
|
||||
} catch (error) {
|
||||
if (this.records.get(sessionId) === record) {
|
||||
this.records.delete(sessionId)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables event forwarding after the create response has crossed IPC. Events
|
||||
* produced while a PTY/channel was starting are retained and delivered first.
|
||||
*/
|
||||
enableEventDelivery(
|
||||
ownerWebContentsId: number,
|
||||
sessionId: string
|
||||
): void {
|
||||
const record = this.requireOwned(ownerWebContentsId, sessionId)
|
||||
if (record.deliveryEnabled) {
|
||||
return
|
||||
}
|
||||
record.deliveryEnabled = true
|
||||
for (const event of record.delayedEvents.splice(0)) {
|
||||
this.deliver(record, event)
|
||||
}
|
||||
}
|
||||
|
||||
write(
|
||||
ownerWebContentsId: number,
|
||||
sessionId: string,
|
||||
data: string
|
||||
): boolean {
|
||||
const record = this.requireOwned(ownerWebContentsId, sessionId)
|
||||
const session = this.requireRunning(record)
|
||||
return session.write(data) !== false
|
||||
}
|
||||
|
||||
resize(
|
||||
ownerWebContentsId: number,
|
||||
sessionId: string,
|
||||
size: TerminalSize
|
||||
): boolean {
|
||||
const record = this.requireOwned(ownerWebContentsId, sessionId)
|
||||
const session = this.requireRunning(record)
|
||||
return session.resize(size) !== false
|
||||
}
|
||||
|
||||
snapshot(
|
||||
ownerWebContentsId: number,
|
||||
sessionId: string
|
||||
): TerminalSnapshot {
|
||||
const record = this.requireOwned(ownerWebContentsId, sessionId)
|
||||
if (!record.session) {
|
||||
throw new TerminalSessionManagerError(
|
||||
'session-not-running',
|
||||
'终端会话仍在启动'
|
||||
)
|
||||
}
|
||||
return record.session.snapshot()
|
||||
}
|
||||
|
||||
acknowledge(
|
||||
ownerWebContentsId: number,
|
||||
sessionId: string,
|
||||
sequence: number
|
||||
): void {
|
||||
this.assertOwnerId(ownerWebContentsId)
|
||||
const record = this.records.get(sessionId)
|
||||
if (!record) {
|
||||
// ACKs already queued in the renderer may arrive after a successful
|
||||
// close. They are delivery bookkeeping, so treating them as complete is
|
||||
// both safe and necessary for a race-free close path.
|
||||
return
|
||||
}
|
||||
if (record.ownerId !== ownerWebContentsId) {
|
||||
throw new TerminalSessionManagerError(
|
||||
'session-not-found',
|
||||
'终端会话不存在'
|
||||
)
|
||||
}
|
||||
if (!record.session || record.closing) {
|
||||
return
|
||||
}
|
||||
record.session.acknowledge(sequence)
|
||||
}
|
||||
|
||||
close(
|
||||
ownerWebContentsId: number,
|
||||
sessionId: string
|
||||
): Promise<TerminalSnapshot> {
|
||||
const record = this.requireOwned(ownerWebContentsId, sessionId)
|
||||
return this.closeRecord(record)
|
||||
}
|
||||
|
||||
async closeOwner(ownerWebContentsId: number): Promise<void> {
|
||||
this.assertOwnerId(ownerWebContentsId)
|
||||
const records = [...this.records.values()].filter(
|
||||
(record) => record.ownerId === ownerWebContentsId
|
||||
)
|
||||
await Promise.allSettled(records.map((record) => this.closeRecord(record)))
|
||||
for (const record of records) {
|
||||
record.removeEventListener?.()
|
||||
if (this.records.get(record.sessionId) === record) {
|
||||
this.records.delete(record.sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): Promise<void> {
|
||||
this.disposePromise ??= this.disposeInternal()
|
||||
return this.disposePromise
|
||||
}
|
||||
|
||||
private async disposeInternal(): Promise<void> {
|
||||
this.disposed = true
|
||||
const records = [...this.records.values()]
|
||||
await Promise.allSettled(records.map((record) => this.closeRecord(record)))
|
||||
for (const record of records) {
|
||||
record.removeEventListener?.()
|
||||
}
|
||||
this.records.clear()
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
record: SessionRecord,
|
||||
request: TerminalCreateRequest
|
||||
): Promise<ManagedTerminalSession> {
|
||||
const launch = await this.resolveLaunch(request)
|
||||
const common = {
|
||||
sessionId: record.sessionId,
|
||||
target: request.target,
|
||||
targetLabel: launch.targetLabel,
|
||||
title: `终端 · ${launch.targetLabel}`,
|
||||
size: { cols: request.cols, rows: request.rows }
|
||||
}
|
||||
let session: ManagedTerminalSession
|
||||
if (launch.kind === 'local') {
|
||||
session = await (
|
||||
this.dependencies.createLocalSession ??
|
||||
((options) => LocalTerminalSession.create(options))
|
||||
)({
|
||||
...common,
|
||||
target: request.target as Extract<
|
||||
TerminalCreateRequest['target'],
|
||||
{ type: 'local' | 'project' }
|
||||
>,
|
||||
projectDirectory: launch.projectDirectory
|
||||
})
|
||||
record.session = session
|
||||
record.removeEventListener = session.onEvent?.((event) =>
|
||||
this.handleEvent(record, event)
|
||||
)
|
||||
} else {
|
||||
session = await (
|
||||
this.dependencies.createSshSession ??
|
||||
((pool, options) =>
|
||||
createSshTerminalSession(pool, options) as Promise<SshTerminalSession>)
|
||||
)(this.dependencies.sshPool, {
|
||||
...common,
|
||||
workingDirectory: launch.workingDirectory,
|
||||
poolTarget: launch.poolTarget,
|
||||
onEvent: (event) => this.handleEvent(record, event)
|
||||
})
|
||||
record.session = session
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private async resolveLaunch(
|
||||
request: TerminalCreateRequest
|
||||
): Promise<ResolvedLaunch> {
|
||||
if (request.target.type === 'local') {
|
||||
return { kind: 'local', targetLabel: '本机' }
|
||||
}
|
||||
|
||||
let project: ReturnType<AssistantDatabase['getProject']>
|
||||
try {
|
||||
project = this.dependencies.database.getProject(
|
||||
request.target.projectId
|
||||
)
|
||||
} catch (error) {
|
||||
throw this.resolutionError('target-not-found', error, '项目不存在')
|
||||
}
|
||||
let executionSpace: ExecutionSpaceDescriptor
|
||||
try {
|
||||
executionSpace =
|
||||
this.dependencies.executionSpaceResolver.resolveProject(project)
|
||||
} catch (error) {
|
||||
throw this.resolutionError(
|
||||
'target-unavailable',
|
||||
error,
|
||||
'项目执行空间不可用'
|
||||
)
|
||||
}
|
||||
if (executionSpace.kind === 'local') {
|
||||
return {
|
||||
kind: 'local',
|
||||
targetLabel: project.name,
|
||||
projectDirectory: executionSpace.rootPath
|
||||
}
|
||||
}
|
||||
await this.assertRemoteEnabled()
|
||||
const poolTarget = await this.resolveTarget(executionSpace.hostId)
|
||||
return {
|
||||
kind: 'ssh',
|
||||
targetLabel: poolTarget.host.name,
|
||||
workingDirectory: executionSpace.remoteRootPath,
|
||||
poolTarget
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveTarget(
|
||||
hostId: string
|
||||
): Promise<SshConnectionPoolTarget> {
|
||||
try {
|
||||
return await this.dependencies.targetResolver.resolve(hostId)
|
||||
} catch (error) {
|
||||
throw this.resolutionError(
|
||||
'target-unavailable',
|
||||
error,
|
||||
'SSH 主机不可用'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async assertRemoteEnabled(): Promise<void> {
|
||||
if (!(await this.dependencies.remoteEnabled())) {
|
||||
throw new TerminalSessionManagerError(
|
||||
'target-unavailable',
|
||||
'远程终端当前未启用'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(record: SessionRecord, event: TerminalEvent): void {
|
||||
if (this.records.get(record.sessionId) !== record) {
|
||||
return
|
||||
}
|
||||
if (!record.deliveryEnabled) {
|
||||
record.delayedEvents.push(event)
|
||||
return
|
||||
}
|
||||
this.deliver(record, event)
|
||||
}
|
||||
|
||||
private deliver(record: SessionRecord, event: TerminalEvent): void {
|
||||
try {
|
||||
this.dependencies.deliverEvent(record.ownerId, event)
|
||||
} catch {
|
||||
// A destroyed renderer is cleaned up by closeOwner; it must not break
|
||||
// PTY/SSH event handling while that close is being scheduled.
|
||||
}
|
||||
}
|
||||
|
||||
private requireRunning(record: SessionRecord): ManagedTerminalSession {
|
||||
const session = record.session
|
||||
if (
|
||||
!session ||
|
||||
record.closing ||
|
||||
session.snapshot().state !== 'running'
|
||||
) {
|
||||
throw new TerminalSessionManagerError(
|
||||
'session-not-running',
|
||||
'终端会话未在运行'
|
||||
)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
private closeRecord(record: SessionRecord): Promise<TerminalSnapshot> {
|
||||
record.closing = true
|
||||
record.closePromise ??= (async () => {
|
||||
try {
|
||||
const session = await record.ready
|
||||
const closed = await session.close()
|
||||
const snapshot =
|
||||
closed && typeof closed === 'object' && 'sessionId' in closed
|
||||
? closed
|
||||
: session.snapshot()
|
||||
record.removeEventListener?.()
|
||||
record.removeEventListener = undefined
|
||||
record.delayedEvents.length = 0
|
||||
if (this.records.get(record.sessionId) === record) {
|
||||
this.records.delete(record.sessionId)
|
||||
}
|
||||
return snapshot
|
||||
} catch (error) {
|
||||
record.closing = false
|
||||
record.closePromise = undefined
|
||||
throw error
|
||||
}
|
||||
})()
|
||||
return record.closePromise
|
||||
}
|
||||
|
||||
private requireOwned(
|
||||
ownerWebContentsId: number,
|
||||
sessionId: string
|
||||
): SessionRecord {
|
||||
this.assertOwnerId(ownerWebContentsId)
|
||||
const record = this.records.get(sessionId)
|
||||
if (!record || record.ownerId !== ownerWebContentsId) {
|
||||
throw new TerminalSessionManagerError(
|
||||
'session-not-found',
|
||||
'终端会话不存在'
|
||||
)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
private ownerSessionCount(ownerId: number): number {
|
||||
let count = 0
|
||||
for (const record of this.records.values()) {
|
||||
if (record.ownerId === ownerId && !record.closing) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private assertOwnerId(ownerId: number): void {
|
||||
if (!Number.isSafeInteger(ownerId) || ownerId < 0) {
|
||||
throw new TypeError('无效的终端窗口标识')
|
||||
}
|
||||
}
|
||||
|
||||
private resolutionError(
|
||||
code: TerminalErrorCode,
|
||||
cause: unknown,
|
||||
fallback: string
|
||||
): TerminalSessionManagerError {
|
||||
return new TerminalSessionManagerError(
|
||||
code,
|
||||
cause instanceof Error && cause.message
|
||||
? cause.message
|
||||
: fallback
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,16 @@ import type {
|
||||
RemoteProjectRecoverySnapshot,
|
||||
RemoteProjectRecoveryState
|
||||
} from '../shared/remote-project-recovery-contracts'
|
||||
import type {
|
||||
TerminalAckRequest,
|
||||
TerminalCloseRequest,
|
||||
TerminalCreateRequest,
|
||||
TerminalEvent,
|
||||
TerminalResizeRequest,
|
||||
TerminalSnapshot,
|
||||
TerminalSnapshotRequest,
|
||||
TerminalWriteRequest
|
||||
} from '../shared/terminal-contracts'
|
||||
|
||||
const desktopApi: DesktopApi = {
|
||||
app: {
|
||||
@@ -302,6 +312,41 @@ const desktopApi: DesktopApi = {
|
||||
ipcRenderer.removeListener(ipcChannels.browserState, handler)
|
||||
}
|
||||
},
|
||||
terminal: {
|
||||
create: (request: TerminalCreateRequest) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.terminalCreate,
|
||||
request
|
||||
) as Promise<TerminalSnapshot>,
|
||||
write: async (request: TerminalWriteRequest) => {
|
||||
await ipcRenderer.invoke(ipcChannels.terminalWrite, request)
|
||||
},
|
||||
resize: async (request: TerminalResizeRequest) => {
|
||||
await ipcRenderer.invoke(ipcChannels.terminalResize, request)
|
||||
},
|
||||
close: (request: TerminalCloseRequest) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.terminalClose,
|
||||
request
|
||||
) as Promise<TerminalSnapshot>,
|
||||
getSnapshot: (request: TerminalSnapshotRequest) =>
|
||||
ipcRenderer.invoke(
|
||||
ipcChannels.terminalSnapshot,
|
||||
request
|
||||
) as Promise<TerminalSnapshot>,
|
||||
ack: async (request: TerminalAckRequest) => {
|
||||
await ipcRenderer.invoke(ipcChannels.terminalAck, request)
|
||||
},
|
||||
onEvent: (listener) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload: TerminalEvent
|
||||
): void => listener(payload)
|
||||
ipcRenderer.on(ipcChannels.terminalEvent, handler)
|
||||
return () =>
|
||||
ipcRenderer.removeListener(ipcChannels.terminalEvent, handler)
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
getRuntime: () =>
|
||||
ipcRenderer.invoke(
|
||||
|
||||
@@ -228,6 +228,28 @@ describe('sandboxed preload', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes only bounded terminal session operations', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
||||
'utf8'
|
||||
)
|
||||
const terminal =
|
||||
source.match(
|
||||
/terminal: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}settings:/u
|
||||
)?.groups?.body ?? ''
|
||||
expect(terminal).toContain('create:')
|
||||
expect(terminal).toContain('write:')
|
||||
expect(terminal).toContain('resize:')
|
||||
expect(terminal).toContain('close:')
|
||||
expect(terminal).toContain('getSnapshot:')
|
||||
expect(terminal).toContain('ack:')
|
||||
expect(terminal).toContain('onEvent:')
|
||||
expect(terminal).toContain('ipcRenderer.removeListener(')
|
||||
expect(terminal).not.toMatch(
|
||||
/\b(?:shell|cwd|command|environment|password|privateKey|pid)\b/u
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes narrow awaited remote project activation and save operations', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'src', 'preload', 'index.ts'),
|
||||
|
||||
@@ -18,6 +18,9 @@ import type {
|
||||
} from '../../shared/contracts'
|
||||
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
|
||||
import type { GlobalShortcutSettingsSnapshot } from '../../shared/shortcut'
|
||||
import type {
|
||||
TerminalSnapshot
|
||||
} from '../../shared/terminal-contracts'
|
||||
import {
|
||||
loadBrandingPreferences,
|
||||
saveBrandingPreferences
|
||||
@@ -99,6 +102,12 @@ vi.mock('./ActivityPanel', async (importOriginal) => {
|
||||
return importOriginal<typeof import('./ActivityPanel')>()
|
||||
})
|
||||
|
||||
vi.mock('./TerminalPanel', () => ({
|
||||
TerminalPanel: ({ title }: { title?: string }) => (
|
||||
<section aria-label={`用户终端:${title ?? '终端'}`} />
|
||||
)
|
||||
}))
|
||||
|
||||
import App from './App'
|
||||
import { loadActivityRecords } from './activity-store'
|
||||
import { changeUiLocale } from './i18n'
|
||||
@@ -155,6 +164,20 @@ const project = {
|
||||
updatedAt: '2026-07-31T00:00:00.000Z'
|
||||
}
|
||||
|
||||
const terminalSnapshot: TerminalSnapshot = {
|
||||
sessionId: '00000000-0000-4000-8000-000000000901',
|
||||
target: { type: 'local' },
|
||||
targetLabel: '本机',
|
||||
title: '终端 1',
|
||||
state: 'running',
|
||||
shell: 'PowerShell 7',
|
||||
workingDirectory: 'C:\\Users\\test',
|
||||
size: { cols: 80, rows: 24 },
|
||||
lastSequence: 0,
|
||||
exit: null,
|
||||
error: null
|
||||
}
|
||||
|
||||
const api: DesktopApi = {
|
||||
app: {
|
||||
getInfo: vi.fn(async () => ({
|
||||
@@ -231,6 +254,23 @@ const api: DesktopApi = {
|
||||
}
|
||||
})
|
||||
},
|
||||
terminal: {
|
||||
create: vi.fn(async (request) => ({
|
||||
...terminalSnapshot,
|
||||
target: request.target,
|
||||
size: { cols: request.cols, rows: request.rows }
|
||||
})),
|
||||
write: vi.fn(async () => undefined),
|
||||
resize: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () => ({
|
||||
...terminalSnapshot,
|
||||
state: 'exited' as const,
|
||||
exit: { exitCode: 0, signal: null }
|
||||
})),
|
||||
getSnapshot: vi.fn(async () => terminalSnapshot),
|
||||
ack: vi.fn(async () => undefined),
|
||||
onEvent: vi.fn(() => () => {})
|
||||
},
|
||||
speech: {
|
||||
transcribe: vi.fn(async () => ({ text: '本地语音结果' })),
|
||||
cancel: vi.fn(async () => true)
|
||||
@@ -5462,6 +5502,35 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens multiple current-project terminals without a second target picker', async () => {
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('切换助手工作栏'))
|
||||
const add = await screen.findByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
for (let count = 1; count <= 2; count += 1) {
|
||||
fireEvent.click(add)
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /打开当前项目的用户终端/u
|
||||
})
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('tab', {
|
||||
name: `终端 ${count}`
|
||||
})
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
}
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开工作栏应用' })
|
||||
).toBeVisible()
|
||||
expect(
|
||||
screen.getAllByRole('tab', { name: /^终端 \d+$/u })
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('opens workspace entries from their row actions', async () => {
|
||||
vi.mocked(api.workspace.listDirectory).mockResolvedValue({
|
||||
path: '',
|
||||
@@ -5683,7 +5752,7 @@ describe('App', () => {
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
).toBeInTheDocument()
|
||||
await waitFor(() =>
|
||||
expect(api.usage.getTokenSummary).toHaveBeenCalledOnce()
|
||||
)
|
||||
@@ -9567,7 +9636,7 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes ignored heartbeat suggestions from the navigation badge', async () => {
|
||||
@@ -9907,6 +9976,88 @@ describe('App', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resizes and remembers the docked primary sidebar width', () => {
|
||||
const originalWidth = window.innerWidth
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1200
|
||||
})
|
||||
const { container, unmount } = render(<App />)
|
||||
|
||||
try {
|
||||
const sidebar =
|
||||
container.querySelector<HTMLElement>('.sidebar')
|
||||
const separator = screen.getByRole('separator', {
|
||||
name: '调整主侧栏宽度'
|
||||
})
|
||||
const setPointerCapture = vi.fn()
|
||||
const releasePointerCapture = vi.fn()
|
||||
Object.defineProperties(separator, {
|
||||
setPointerCapture: { value: setPointerCapture },
|
||||
hasPointerCapture: { value: () => true },
|
||||
releasePointerCapture: { value: releasePointerCapture }
|
||||
})
|
||||
|
||||
expect(
|
||||
sidebar?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('278px')
|
||||
expect(separator).toHaveAttribute('aria-valuemin', '220')
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '420')
|
||||
|
||||
fireEvent.pointerDown(separator, {
|
||||
button: 0,
|
||||
clientX: 320,
|
||||
pointerId: 17
|
||||
})
|
||||
fireEvent.pointerMove(separator, {
|
||||
clientX: 400,
|
||||
pointerId: 17
|
||||
})
|
||||
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(17)
|
||||
expect(sidebar).toHaveClass('sidebar--resizing')
|
||||
expect(
|
||||
sidebar?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('400px')
|
||||
|
||||
fireEvent.pointerUp(separator, { pointerId: 17 })
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(17)
|
||||
expect(sidebar).not.toHaveClass('sidebar--resizing')
|
||||
expect(
|
||||
localStorage.getItem('goodbuddy.primary-sidebar-width.v1')
|
||||
).toBe('400')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'Home' })
|
||||
expect(
|
||||
sidebar?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('220px')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'ArrowRight' })
|
||||
expect(
|
||||
sidebar?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('236px')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'End' })
|
||||
expect(
|
||||
sidebar?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('420px')
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '420')
|
||||
|
||||
unmount()
|
||||
const persistedRender = render(<App />)
|
||||
expect(
|
||||
persistedRender.container
|
||||
.querySelector<HTMLElement>('.sidebar')
|
||||
?.style.getPropertyValue('--primary-sidebar-width')
|
||||
).toBe('420px')
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: originalWidth
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('docks the assistant workbar without obscuring a narrow workspace', async () => {
|
||||
const originalWidth = window.innerWidth
|
||||
const { container } = render(<App />)
|
||||
@@ -9994,7 +10145,7 @@ describe('App', () => {
|
||||
)
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
).toBeInTheDocument()
|
||||
} finally {
|
||||
delete api.updates
|
||||
}
|
||||
@@ -10206,7 +10357,7 @@ describe('App', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText('切换助手工作栏')
|
||||
).not.toBeInTheDocument()
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^专家角色:/u })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
+247
-24
@@ -558,9 +558,66 @@ const storageKey = 'goodbuddy.conversations.v1'
|
||||
|
||||
const activeProjectStorageKey = 'goodbuddy.active-project.v1'
|
||||
|
||||
const primarySidebarWidthStorageKey =
|
||||
'goodbuddy.primary-sidebar-width.v1'
|
||||
const defaultPrimarySidebarWidth = 278
|
||||
const compactPrimarySidebarWidth = 236
|
||||
const minimumPrimarySidebarWidth = 220
|
||||
const maximumPrimarySidebarWidth = 420
|
||||
const minimumPrimaryWorkspaceWidth = 480
|
||||
const primarySidebarKeyboardResizeStep = 16
|
||||
|
||||
const maxMessageContentLength = 1_000_000
|
||||
const maxMessageBlocks = 500
|
||||
|
||||
function getPrimarySidebarWidthLimits(viewportWidth: number): {
|
||||
minimum: number
|
||||
maximum: number
|
||||
} {
|
||||
const availableWidth = Math.max(0, Math.floor(viewportWidth))
|
||||
return {
|
||||
minimum: minimumPrimarySidebarWidth,
|
||||
maximum: Math.max(
|
||||
minimumPrimarySidebarWidth,
|
||||
Math.min(
|
||||
maximumPrimarySidebarWidth,
|
||||
availableWidth - minimumPrimaryWorkspaceWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function clampPrimarySidebarWidth(
|
||||
width: number,
|
||||
viewportWidth: number
|
||||
): number {
|
||||
const limits = getPrimarySidebarWidthLimits(viewportWidth)
|
||||
return Math.min(
|
||||
limits.maximum,
|
||||
Math.max(limits.minimum, Math.round(width))
|
||||
)
|
||||
}
|
||||
|
||||
function loadPrimarySidebarWidth(): number {
|
||||
const fallback =
|
||||
window.innerWidth <= 1020
|
||||
? compactPrimarySidebarWidth
|
||||
: defaultPrimarySidebarWidth
|
||||
try {
|
||||
const persistedWidth = Number(
|
||||
localStorage.getItem(primarySidebarWidthStorageKey)
|
||||
)
|
||||
return clampPrimarySidebarWidth(
|
||||
Number.isFinite(persistedWidth) && persistedWidth > 0
|
||||
? persistedWidth
|
||||
: fallback,
|
||||
window.innerWidth
|
||||
)
|
||||
} catch {
|
||||
return clampPrimarySidebarWidth(fallback, window.innerWidth)
|
||||
}
|
||||
}
|
||||
|
||||
function appendMessageContentBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
type: 'text' | 'reasoning',
|
||||
@@ -2298,6 +2355,11 @@ function App(): React.JSX.Element {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(
|
||||
() => window.innerWidth >= 900
|
||||
)
|
||||
const [primarySidebarWidth, setPrimarySidebarWidth] = useState(
|
||||
loadPrimarySidebarWidth
|
||||
)
|
||||
const [primarySidebarResizing, setPrimarySidebarResizing] =
|
||||
useState(false)
|
||||
const [assistantSidebarOpen, setAssistantSidebarOpen] = useState(
|
||||
() => window.innerWidth >= 1280
|
||||
)
|
||||
@@ -2647,6 +2709,10 @@ function App(): React.JSX.Element {
|
||||
>({})
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||
const livePrimarySidebarWidthRef = useRef(primarySidebarWidth)
|
||||
const primarySidebarResizePointerIdRef = useRef<number | undefined>(
|
||||
undefined
|
||||
)
|
||||
const assistantSidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||
const conversationActionTriggerRefs = useRef(
|
||||
new Map<string, HTMLButtonElement>()
|
||||
@@ -2673,6 +2739,71 @@ function App(): React.JSX.Element {
|
||||
setSidebarOpen(false)
|
||||
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
|
||||
}, [])
|
||||
const resizePrimarySidebarFromClientX = useCallback(
|
||||
(clientX: number, commit: boolean): void => {
|
||||
const width = clampPrimarySidebarWidth(
|
||||
clientX,
|
||||
window.innerWidth
|
||||
)
|
||||
livePrimarySidebarWidthRef.current = width
|
||||
if (commit) {
|
||||
setPrimarySidebarWidth(width)
|
||||
return
|
||||
}
|
||||
sidebarRef.current?.style.setProperty(
|
||||
'--primary-sidebar-width',
|
||||
`${width}px`
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const finishPrimarySidebarResize = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (
|
||||
primarySidebarResizePointerIdRef.current !== event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
primarySidebarResizePointerIdRef.current = undefined
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
setPrimarySidebarWidth(livePrimarySidebarWidthRef.current)
|
||||
setPrimarySidebarResizing(false)
|
||||
},
|
||||
[]
|
||||
)
|
||||
const resizePrimarySidebarWithKeyboard = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (narrowWindow || !sidebarOpen) {
|
||||
return
|
||||
}
|
||||
const limits = getPrimarySidebarWidthLimits(window.innerWidth)
|
||||
const nextWidth =
|
||||
event.key === 'Home'
|
||||
? limits.minimum
|
||||
: event.key === 'End'
|
||||
? limits.maximum
|
||||
: event.key === 'ArrowLeft'
|
||||
? primarySidebarWidth -
|
||||
primarySidebarKeyboardResizeStep
|
||||
: event.key === 'ArrowRight'
|
||||
? primarySidebarWidth +
|
||||
primarySidebarKeyboardResizeStep
|
||||
: undefined
|
||||
if (nextWidth === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const width = clampPrimarySidebarWidth(
|
||||
nextWidth,
|
||||
window.innerWidth
|
||||
)
|
||||
livePrimarySidebarWidthRef.current = width
|
||||
setPrimarySidebarWidth(width)
|
||||
},
|
||||
[narrowWindow, primarySidebarWidth, sidebarOpen]
|
||||
)
|
||||
const navigateFromSidebar = useCallback(
|
||||
(nextView: WorkspaceView, trigger: HTMLElement): void => {
|
||||
if (viewRef.current !== 'settings' && nextView === 'settings') {
|
||||
@@ -2788,12 +2919,30 @@ function App(): React.JSX.Element {
|
||||
return () => window.clearInterval(interval)
|
||||
}, [activeId, assistantTasks, knowledgeOperationCount, view])
|
||||
|
||||
useEffect(() => {
|
||||
livePrimarySidebarWidthRef.current = primarySidebarWidth
|
||||
try {
|
||||
localStorage.setItem(
|
||||
primarySidebarWidthStorageKey,
|
||||
String(primarySidebarWidth)
|
||||
)
|
||||
} catch {
|
||||
// The current width remains usable when browser storage is unavailable.
|
||||
}
|
||||
}, [primarySidebarWidth])
|
||||
|
||||
useEffect(() => {
|
||||
const collapseSidebarAtNarrowWidth = (): void => {
|
||||
const narrow = window.innerWidth < 900
|
||||
setNarrowWindow(narrow)
|
||||
if (narrow) {
|
||||
setSidebarOpen(false)
|
||||
setPrimarySidebarResizing(false)
|
||||
primarySidebarResizePointerIdRef.current = undefined
|
||||
} else {
|
||||
setPrimarySidebarWidth((current) =>
|
||||
clampPrimarySidebarWidth(current, window.innerWidth)
|
||||
)
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', collapseSidebarAtNarrowWidth)
|
||||
@@ -8033,6 +8182,9 @@ function App(): React.JSX.Element {
|
||||
])
|
||||
|
||||
const mainSidebarOpen = narrowWindow && sidebarOpen
|
||||
const canResizePrimarySidebar = sidebarOpen && !narrowWindow
|
||||
const primarySidebarWidthLimits =
|
||||
getPrimarySidebarWidthLimits(window.innerWidth)
|
||||
const backgroundIsolated = mainSidebarOpen
|
||||
const runtimeState =
|
||||
runtimeSwitching ||
|
||||
@@ -8060,7 +8212,17 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
aria-hidden={!sidebarOpen}
|
||||
aria-modal={narrowWindow && sidebarOpen ? 'true' : undefined}
|
||||
className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}
|
||||
className={
|
||||
sidebarOpen
|
||||
? `sidebar${
|
||||
primarySidebarResizing &&
|
||||
canResizePrimarySidebar
|
||||
? ' sidebar--resizing'
|
||||
: ''
|
||||
}`
|
||||
: 'sidebar sidebar--closed'
|
||||
}
|
||||
id="primary-sidebar"
|
||||
inert={!sidebarOpen}
|
||||
onKeyDown={(event) => {
|
||||
if (mainSidebarOpen) {
|
||||
@@ -8069,6 +8231,11 @@ function App(): React.JSX.Element {
|
||||
}}
|
||||
ref={sidebarRef}
|
||||
role={narrowWindow && sidebarOpen ? 'dialog' : undefined}
|
||||
style={
|
||||
{
|
||||
'--primary-sidebar-width': `${primarySidebarWidth}px`
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<BrandLockup
|
||||
className="brand"
|
||||
@@ -8757,6 +8924,65 @@ function App(): React.JSX.Element {
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
aria-controls="primary-sidebar"
|
||||
aria-disabled={!canResizePrimarySidebar}
|
||||
aria-label={t('sidebar.resizeAriaLabel')}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={primarySidebarWidthLimits.maximum}
|
||||
aria-valuemin={primarySidebarWidthLimits.minimum}
|
||||
aria-valuenow={primarySidebarWidth}
|
||||
aria-valuetext={t('sidebar.resizeValue', {
|
||||
width: primarySidebarWidth
|
||||
})}
|
||||
className="primary-sidebar-resize-handle"
|
||||
onKeyDown={resizePrimarySidebarWithKeyboard}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (
|
||||
primarySidebarResizePointerIdRef.current ===
|
||||
event.pointerId
|
||||
) {
|
||||
primarySidebarResizePointerIdRef.current = undefined
|
||||
setPrimarySidebarWidth(
|
||||
livePrimarySidebarWidthRef.current
|
||||
)
|
||||
setPrimarySidebarResizing(false)
|
||||
}
|
||||
}}
|
||||
onPointerCancel={finishPrimarySidebarResize}
|
||||
onPointerDown={(event) => {
|
||||
if (
|
||||
event.button !== 0 ||
|
||||
!canResizePrimarySidebar
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
primarySidebarResizePointerIdRef.current = event.pointerId
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
resizePrimarySidebarFromClientX(event.clientX, true)
|
||||
setPrimarySidebarResizing(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (
|
||||
primarySidebarResizePointerIdRef.current !==
|
||||
event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!canResizePrimarySidebar) {
|
||||
finishPrimarySidebarResize(event)
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
resizePrimarySidebarFromClientX(event.clientX, false)
|
||||
}}
|
||||
onPointerUp={finishPrimarySidebarResize}
|
||||
role="separator"
|
||||
tabIndex={canResizePrimarySidebar ? 0 : -1}
|
||||
/>
|
||||
)}
|
||||
{sidebarOpen && (
|
||||
<button
|
||||
aria-label={t('sidebar.close')}
|
||||
@@ -8882,26 +9108,24 @@ function App(): React.JSX.Element {
|
||||
</header>
|
||||
|
||||
<div className="app-content">
|
||||
{view === 'chat' && (
|
||||
<button
|
||||
aria-controls="assistant-sidebar"
|
||||
aria-expanded={assistantSidebarOpen}
|
||||
aria-label={t('topbar.toggleAssistantSidebar')}
|
||||
className="icon-button assistant-sidebar-toggle"
|
||||
onClick={() =>
|
||||
setAssistantSidebarOpen((current) => !current)
|
||||
}
|
||||
ref={assistantSidebarToggleRef}
|
||||
title={t('topbar.toggleAssistantSidebar')}
|
||||
type="button"
|
||||
>
|
||||
{assistantSidebarOpen ? (
|
||||
<PanelRightClose aria-hidden="true" size={18} />
|
||||
) : (
|
||||
<PanelRightOpen aria-hidden="true" size={18} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-controls="assistant-sidebar"
|
||||
aria-expanded={assistantSidebarOpen}
|
||||
aria-label={t('topbar.toggleAssistantSidebar')}
|
||||
className="icon-button assistant-sidebar-toggle"
|
||||
onClick={() =>
|
||||
setAssistantSidebarOpen((current) => !current)
|
||||
}
|
||||
ref={assistantSidebarToggleRef}
|
||||
title={t('topbar.toggleAssistantSidebar')}
|
||||
type="button"
|
||||
>
|
||||
{assistantSidebarOpen ? (
|
||||
<PanelRightClose aria-hidden="true" size={18} />
|
||||
) : (
|
||||
<PanelRightOpen aria-hidden="true" size={18} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<main
|
||||
aria-hidden={backgroundIsolated ? 'true' : undefined}
|
||||
@@ -10602,6 +10826,7 @@ function App(): React.JSX.Element {
|
||||
artifacts={sidebarArtifacts}
|
||||
browserState={browserStates[activeId]}
|
||||
conversationTitles={conversationTitles}
|
||||
currentProject={activeProject}
|
||||
onCreateCustomTask={() => openCustomTaskDialog('current')}
|
||||
schedules={assistantSchedules}
|
||||
selectedTaskId={selectedAssistantTaskId}
|
||||
@@ -10682,9 +10907,7 @@ function App(): React.JSX.Element {
|
||||
onOpenWorkspaceEntry={openWorkspaceEntry}
|
||||
onRefreshChanges={refreshWorkspaceChanges}
|
||||
onTabChange={setAssistantSidebarTab}
|
||||
open={
|
||||
assistantSidebarOpen && view === 'chat'
|
||||
}
|
||||
open={assistantSidebarOpen}
|
||||
restoreFocusRef={assistantSidebarToggleRef}
|
||||
tab={assistantSidebarTab}
|
||||
workspaceChanges={workspaceChanges}
|
||||
|
||||
@@ -198,6 +198,7 @@ const getApplicationSettings = vi.fn<() => Promise<ApplicationSettings>>(async (
|
||||
const onNotify = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
analysisEventListener = undefined
|
||||
getApplicationSettings.mockResolvedValue({
|
||||
checkUpdatesOnStartup: false,
|
||||
@@ -528,6 +529,155 @@ describe('MagicNotesWorkspace', () => {
|
||||
expect(pane).toBeVisible()
|
||||
})
|
||||
|
||||
it('hides, restores, and remembers the left list pane', async () => {
|
||||
const firstRender = render(
|
||||
<MagicNotesWorkspace onNotify={onNotify} />
|
||||
)
|
||||
|
||||
const pane = await screen.findByLabelText('笔记列表')
|
||||
const hideButton = screen.getByRole('button', {
|
||||
name: '隐藏左侧列表'
|
||||
})
|
||||
expect(hideButton).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(pane).toBeVisible()
|
||||
|
||||
fireEvent.click(hideButton)
|
||||
expect(pane).not.toBeVisible()
|
||||
expect(
|
||||
screen.queryByRole('separator', {
|
||||
name: '调整笔记列表与编辑区宽度'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem('goodbuddy.magic-notes-layout.v1') ?? '{}'
|
||||
)
|
||||
).toMatchObject({ listPaneOpen: false })
|
||||
|
||||
firstRender.unmount()
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
const restoredPane = await screen.findByLabelText('笔记列表')
|
||||
expect(restoredPane).not.toBeVisible()
|
||||
|
||||
const showButton = screen.getByRole('button', {
|
||||
name: '显示左侧列表'
|
||||
})
|
||||
expect(showButton).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(showButton)
|
||||
expect(restoredPane).toBeVisible()
|
||||
expect(
|
||||
screen.getByRole('separator', {
|
||||
name: '调整笔记列表与编辑区宽度'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resizes the left list pane with pointer and keyboard controls', async () => {
|
||||
const originalInnerWidth = window.innerWidth
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1200
|
||||
})
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
|
||||
try {
|
||||
await screen.findByText('记录正文')
|
||||
const separator = screen.getByRole('separator', {
|
||||
name: '调整笔记列表与编辑区宽度'
|
||||
})
|
||||
const layout =
|
||||
separator.closest('.magic-notes-layout') as HTMLElement
|
||||
const setPointerCapture = vi.fn()
|
||||
const releasePointerCapture = vi.fn()
|
||||
Object.defineProperties(separator, {
|
||||
setPointerCapture: { value: setPointerCapture },
|
||||
hasPointerCapture: { value: () => true },
|
||||
releasePointerCapture: { value: releasePointerCapture }
|
||||
})
|
||||
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '220')
|
||||
expect(separator).toHaveAttribute('aria-valuemin', '180')
|
||||
expect(separator).toHaveAttribute('aria-valuemax', '360')
|
||||
|
||||
fireEvent.pointerDown(separator, {
|
||||
button: 0,
|
||||
clientX: 220,
|
||||
pointerId: 13
|
||||
})
|
||||
fireEvent.pointerMove(separator, {
|
||||
clientX: 350,
|
||||
pointerId: 13
|
||||
})
|
||||
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(13)
|
||||
expect(layout).toHaveClass('magic-notes-layout--resizing')
|
||||
expect(layout).toHaveClass('magic-notes-layout--list-resizing')
|
||||
expect(
|
||||
layout.style.getPropertyValue('--magic-notes-list-width')
|
||||
).toBe('350px')
|
||||
|
||||
fireEvent.pointerUp(separator, { pointerId: 13 })
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(13)
|
||||
expect(layout).not.toHaveClass('magic-notes-layout--resizing')
|
||||
expect(
|
||||
JSON.parse(
|
||||
localStorage.getItem(
|
||||
'goodbuddy.magic-notes-layout.v1'
|
||||
) ?? '{}'
|
||||
)
|
||||
).toMatchObject({ listPaneWidth: 350 })
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'Home' })
|
||||
expect(
|
||||
layout.style.getPropertyValue('--magic-notes-list-width')
|
||||
).toBe('180px')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'ArrowRight' })
|
||||
expect(
|
||||
layout.style.getPropertyValue('--magic-notes-list-width')
|
||||
).toBe('196px')
|
||||
|
||||
fireEvent.keyDown(separator, { key: 'End' })
|
||||
expect(
|
||||
layout.style.getPropertyValue('--magic-notes-list-width')
|
||||
).toBe('360px')
|
||||
expect(separator).toHaveAttribute('aria-valuenow', '360')
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: originalInnerWidth
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('disables horizontal pane resizing in the narrow stacked layout', async () => {
|
||||
const originalInnerWidth = window.innerWidth
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 700
|
||||
})
|
||||
render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
|
||||
try {
|
||||
await screen.findByText('记录正文')
|
||||
const listSeparator = screen.getByRole('separator', {
|
||||
name: '调整笔记列表与编辑区宽度'
|
||||
})
|
||||
expect(listSeparator).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(listSeparator).toHaveAttribute('tabindex', '-1')
|
||||
const aiSeparator = screen.getByRole('separator', {
|
||||
name: '调整编辑区与 AI 评论宽度'
|
||||
})
|
||||
expect(aiSeparator).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(aiSeparator).toHaveAttribute('tabindex', '-1')
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: originalInnerWidth
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('shows a concise empty state before a note has AI comments', async () => {
|
||||
let resolveDetail:
|
||||
| ((value: MagicNoteDetail) => void)
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
FolderTree,
|
||||
Lightbulb,
|
||||
ListTodo,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Pin,
|
||||
@@ -73,23 +75,107 @@ type DraftSwitchTarget =
|
||||
const defaultAiPaneWidth = 280
|
||||
const minimumAiPaneWidth = 240
|
||||
const maximumAiPaneWidth = 520
|
||||
const magicNotesListPaneWidth = 220
|
||||
const defaultMagicNotesListPaneWidth = 220
|
||||
const minimumMagicNotesListPaneWidth = 180
|
||||
const maximumMagicNotesListPaneWidth = 360
|
||||
const minimumMagicNotesEditorWidth = 300
|
||||
const magicNotesResizeHandleWidth = 9
|
||||
const aiPaneKeyboardResizeStep = 16
|
||||
const magicNotesPaneKeyboardResizeStep = 16
|
||||
const magicNotesLayoutStorageKey =
|
||||
'goodbuddy.magic-notes-layout.v1'
|
||||
|
||||
function getAiPaneWidthLimits(layoutWidth: number): {
|
||||
type MagicNotesLayoutPreferences = {
|
||||
listPaneOpen: boolean
|
||||
listPaneWidth: number
|
||||
aiPaneOpen: boolean
|
||||
aiPaneWidth: number
|
||||
}
|
||||
|
||||
function loadMagicNotesLayoutPreferences(): MagicNotesLayoutPreferences {
|
||||
const defaults = {
|
||||
listPaneOpen: true,
|
||||
listPaneWidth: defaultMagicNotesListPaneWidth,
|
||||
aiPaneOpen: true,
|
||||
aiPaneWidth: defaultAiPaneWidth
|
||||
}
|
||||
try {
|
||||
const value = localStorage.getItem(magicNotesLayoutStorageKey)
|
||||
if (!value || value.length > 10_000) {
|
||||
return defaults
|
||||
}
|
||||
const parsed = JSON.parse(value) as Partial<
|
||||
MagicNotesLayoutPreferences
|
||||
>
|
||||
return {
|
||||
listPaneOpen: parsed.listPaneOpen !== false,
|
||||
listPaneWidth:
|
||||
typeof parsed.listPaneWidth === 'number' &&
|
||||
Number.isFinite(parsed.listPaneWidth)
|
||||
? Math.min(
|
||||
maximumMagicNotesListPaneWidth,
|
||||
Math.max(
|
||||
minimumMagicNotesListPaneWidth,
|
||||
parsed.listPaneWidth
|
||||
)
|
||||
)
|
||||
: defaults.listPaneWidth,
|
||||
aiPaneOpen: parsed.aiPaneOpen !== false,
|
||||
aiPaneWidth:
|
||||
typeof parsed.aiPaneWidth === 'number' &&
|
||||
Number.isFinite(parsed.aiPaneWidth)
|
||||
? Math.min(
|
||||
maximumAiPaneWidth,
|
||||
Math.max(minimumAiPaneWidth, parsed.aiPaneWidth)
|
||||
)
|
||||
: defaults.aiPaneWidth
|
||||
}
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
function persistMagicNotesLayoutPreferences(
|
||||
preferences: MagicNotesLayoutPreferences
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
magicNotesLayoutStorageKey,
|
||||
JSON.stringify(preferences)
|
||||
)
|
||||
} catch {
|
||||
// The in-memory layout remains usable when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
type MagicNotesPaneWidthLimits = {
|
||||
minimum: number
|
||||
maximum: number
|
||||
} {
|
||||
}
|
||||
|
||||
function getMagicNotesPaneWidthLimits({
|
||||
adjacentPaneOpen,
|
||||
adjacentPaneWidth,
|
||||
layoutWidth,
|
||||
maximum,
|
||||
minimum
|
||||
}: {
|
||||
adjacentPaneOpen: boolean
|
||||
adjacentPaneWidth: number
|
||||
layoutWidth: number
|
||||
maximum: number
|
||||
minimum: number
|
||||
}): MagicNotesPaneWidthLimits {
|
||||
const reservedAdjacentWidth = adjacentPaneOpen
|
||||
? adjacentPaneWidth + magicNotesResizeHandleWidth
|
||||
: 0
|
||||
return {
|
||||
minimum: minimumAiPaneWidth,
|
||||
minimum,
|
||||
maximum: Math.max(
|
||||
minimumAiPaneWidth,
|
||||
minimum,
|
||||
Math.min(
|
||||
maximumAiPaneWidth,
|
||||
maximum,
|
||||
layoutWidth -
|
||||
magicNotesListPaneWidth -
|
||||
reservedAdjacentWidth -
|
||||
minimumMagicNotesEditorWidth -
|
||||
magicNotesResizeHandleWidth
|
||||
)
|
||||
@@ -97,9 +183,73 @@ function getAiPaneWidthLimits(layoutWidth: number): {
|
||||
}
|
||||
}
|
||||
|
||||
function clampAiPaneWidth(width: number, layoutWidth: number): number {
|
||||
const limits = getAiPaneWidthLimits(layoutWidth)
|
||||
return Math.min(limits.maximum, Math.max(limits.minimum, width))
|
||||
function clampMagicNotesPaneWidth(
|
||||
width: number,
|
||||
limits: MagicNotesPaneWidthLimits
|
||||
): number {
|
||||
return Math.round(
|
||||
Math.min(limits.maximum, Math.max(limits.minimum, width))
|
||||
)
|
||||
}
|
||||
|
||||
function getAiPaneWidthLimits(
|
||||
layoutWidth: number,
|
||||
listPaneWidth: number,
|
||||
listPaneOpen: boolean
|
||||
): MagicNotesPaneWidthLimits {
|
||||
return getMagicNotesPaneWidthLimits({
|
||||
adjacentPaneOpen: listPaneOpen,
|
||||
adjacentPaneWidth: listPaneWidth,
|
||||
layoutWidth,
|
||||
maximum: maximumAiPaneWidth,
|
||||
minimum: minimumAiPaneWidth
|
||||
})
|
||||
}
|
||||
|
||||
function clampAiPaneWidth(
|
||||
width: number,
|
||||
layoutWidth: number,
|
||||
listPaneWidth: number,
|
||||
listPaneOpen: boolean
|
||||
): number {
|
||||
return clampMagicNotesPaneWidth(
|
||||
width,
|
||||
getAiPaneWidthLimits(
|
||||
layoutWidth,
|
||||
listPaneWidth,
|
||||
listPaneOpen
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getListPaneWidthLimits(
|
||||
layoutWidth: number,
|
||||
aiPaneWidth: number,
|
||||
aiPaneOpen: boolean
|
||||
): MagicNotesPaneWidthLimits {
|
||||
return getMagicNotesPaneWidthLimits({
|
||||
adjacentPaneOpen: aiPaneOpen,
|
||||
adjacentPaneWidth: aiPaneWidth,
|
||||
layoutWidth,
|
||||
maximum: maximumMagicNotesListPaneWidth,
|
||||
minimum: minimumMagicNotesListPaneWidth
|
||||
})
|
||||
}
|
||||
|
||||
function clampListPaneWidth(
|
||||
width: number,
|
||||
layoutWidth: number,
|
||||
aiPaneWidth: number,
|
||||
aiPaneOpen: boolean
|
||||
): number {
|
||||
return clampMagicNotesPaneWidth(
|
||||
width,
|
||||
getListPaneWidthLimits(
|
||||
layoutWidth,
|
||||
aiPaneWidth,
|
||||
aiPaneOpen
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function noteSummary(note: MagicNoteDetail): MagicNoteSummary {
|
||||
@@ -349,8 +499,23 @@ export function MagicNotesWorkspace({
|
||||
const [composerKey, setComposerKey] = useState(0)
|
||||
const [editingEntry, setEditingEntry] = useState<MagicNoteEntry>()
|
||||
const [deletingEntryId, setDeletingEntryId] = useState('')
|
||||
const [aiPaneOpen, setAiPaneOpen] = useState(true)
|
||||
const [aiPaneWidth, setAiPaneWidth] = useState(defaultAiPaneWidth)
|
||||
const initialLayoutPreferences = useMemo(
|
||||
() => loadMagicNotesLayoutPreferences(),
|
||||
[]
|
||||
)
|
||||
const [listPaneOpen, setListPaneOpen] = useState(
|
||||
initialLayoutPreferences.listPaneOpen
|
||||
)
|
||||
const [listPaneWidth, setListPaneWidth] = useState(
|
||||
initialLayoutPreferences.listPaneWidth
|
||||
)
|
||||
const [listPaneResizing, setListPaneResizing] = useState(false)
|
||||
const [aiPaneOpen, setAiPaneOpen] = useState(
|
||||
initialLayoutPreferences.aiPaneOpen
|
||||
)
|
||||
const [aiPaneWidth, setAiPaneWidth] = useState(
|
||||
initialLayoutPreferences.aiPaneWidth
|
||||
)
|
||||
const [aiPaneResizing, setAiPaneResizing] = useState(false)
|
||||
const [magicNotesLayoutWidth, setMagicNotesLayoutWidth] = useState(
|
||||
window.innerWidth
|
||||
@@ -392,7 +557,9 @@ export function MagicNotesWorkspace({
|
||||
const draftAnalysisContextRef = useRef(0)
|
||||
const lastDraftAnalysisStartedAtRef = useRef(0)
|
||||
const magicNotesLayoutRef = useRef<HTMLDivElement>(null)
|
||||
const liveAiPaneWidthRef = useRef(defaultAiPaneWidth)
|
||||
const liveListPaneWidthRef = useRef(listPaneWidth)
|
||||
const listResizePointerIdRef = useRef<number | undefined>(undefined)
|
||||
const liveAiPaneWidthRef = useRef(aiPaneWidth)
|
||||
const aiResizePointerIdRef = useRef<number | undefined>(undefined)
|
||||
const composerRef = useRef<HTMLDivElement>(null)
|
||||
const continueEditingRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -426,12 +593,14 @@ export function MagicNotesWorkspace({
|
||||
)
|
||||
|
||||
const getLayoutBounds = useCallback((): {
|
||||
left: number
|
||||
width: number
|
||||
right: number
|
||||
} => {
|
||||
const bounds = magicNotesLayoutRef.current?.getBoundingClientRect()
|
||||
const width = bounds?.width || window.innerWidth
|
||||
return {
|
||||
left: bounds?.left ?? 0,
|
||||
width,
|
||||
right: bounds?.right || width
|
||||
}
|
||||
@@ -442,7 +611,9 @@ export function MagicNotesWorkspace({
|
||||
const bounds = getLayoutBounds()
|
||||
const width = clampAiPaneWidth(
|
||||
bounds.right - clientX,
|
||||
bounds.width
|
||||
bounds.width,
|
||||
liveListPaneWidthRef.current,
|
||||
listPaneOpen
|
||||
)
|
||||
liveAiPaneWidthRef.current = width
|
||||
if (commit) {
|
||||
@@ -454,7 +625,7 @@ export function MagicNotesWorkspace({
|
||||
`${width}px`
|
||||
)
|
||||
},
|
||||
[getLayoutBounds]
|
||||
[getLayoutBounds, listPaneOpen]
|
||||
)
|
||||
|
||||
const finishAiPaneResize = useCallback(
|
||||
@@ -475,26 +646,106 @@ export function MagicNotesWorkspace({
|
||||
const resizeAiPaneWithKeyboard = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
const bounds = getLayoutBounds()
|
||||
const limits = getAiPaneWidthLimits(bounds.width)
|
||||
const limits = getAiPaneWidthLimits(
|
||||
bounds.width,
|
||||
listPaneWidth,
|
||||
listPaneOpen
|
||||
)
|
||||
const nextWidth =
|
||||
event.key === 'Home'
|
||||
? limits.minimum
|
||||
: event.key === 'End'
|
||||
? limits.maximum
|
||||
: event.key === 'ArrowLeft'
|
||||
? aiPaneWidth + aiPaneKeyboardResizeStep
|
||||
? aiPaneWidth + magicNotesPaneKeyboardResizeStep
|
||||
: event.key === 'ArrowRight'
|
||||
? aiPaneWidth - aiPaneKeyboardResizeStep
|
||||
? aiPaneWidth - magicNotesPaneKeyboardResizeStep
|
||||
: undefined
|
||||
if (nextWidth === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const width = clampAiPaneWidth(nextWidth, bounds.width)
|
||||
const width = clampAiPaneWidth(
|
||||
nextWidth,
|
||||
bounds.width,
|
||||
listPaneWidth,
|
||||
listPaneOpen
|
||||
)
|
||||
liveAiPaneWidthRef.current = width
|
||||
setAiPaneWidth(width)
|
||||
},
|
||||
[aiPaneWidth, getLayoutBounds]
|
||||
[aiPaneWidth, getLayoutBounds, listPaneOpen, listPaneWidth]
|
||||
)
|
||||
|
||||
const resizeListPaneFromClientX = useCallback(
|
||||
(clientX: number, commit: boolean): void => {
|
||||
const bounds = getLayoutBounds()
|
||||
const width = clampListPaneWidth(
|
||||
clientX - bounds.left,
|
||||
bounds.width,
|
||||
liveAiPaneWidthRef.current,
|
||||
aiPaneOpen
|
||||
)
|
||||
liveListPaneWidthRef.current = width
|
||||
if (commit) {
|
||||
setListPaneWidth(width)
|
||||
return
|
||||
}
|
||||
magicNotesLayoutRef.current?.style.setProperty(
|
||||
'--magic-notes-list-width',
|
||||
`${width}px`
|
||||
)
|
||||
},
|
||||
[aiPaneOpen, getLayoutBounds]
|
||||
)
|
||||
|
||||
const finishListPaneResize = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (listResizePointerIdRef.current !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
listResizePointerIdRef.current = undefined
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
}
|
||||
setListPaneWidth(liveListPaneWidthRef.current)
|
||||
setListPaneResizing(false)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const resizeListPaneWithKeyboard = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
||||
const bounds = getLayoutBounds()
|
||||
const limits = getListPaneWidthLimits(
|
||||
bounds.width,
|
||||
aiPaneWidth,
|
||||
aiPaneOpen
|
||||
)
|
||||
const nextWidth =
|
||||
event.key === 'Home'
|
||||
? limits.minimum
|
||||
: event.key === 'End'
|
||||
? limits.maximum
|
||||
: event.key === 'ArrowLeft'
|
||||
? listPaneWidth - magicNotesPaneKeyboardResizeStep
|
||||
: event.key === 'ArrowRight'
|
||||
? listPaneWidth + magicNotesPaneKeyboardResizeStep
|
||||
: undefined
|
||||
if (nextWidth === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const width = clampListPaneWidth(
|
||||
nextWidth,
|
||||
bounds.width,
|
||||
aiPaneWidth,
|
||||
aiPaneOpen
|
||||
)
|
||||
liveListPaneWidthRef.current = width
|
||||
setListPaneWidth(width)
|
||||
},
|
||||
[aiPaneOpen, aiPaneWidth, getLayoutBounds, listPaneWidth]
|
||||
)
|
||||
|
||||
useEffect(
|
||||
@@ -512,33 +763,62 @@ export function MagicNotesWorkspace({
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
persistMagicNotesLayoutPreferences({
|
||||
listPaneOpen,
|
||||
listPaneWidth,
|
||||
aiPaneOpen,
|
||||
aiPaneWidth
|
||||
})
|
||||
}, [aiPaneOpen, aiPaneWidth, listPaneOpen, listPaneWidth])
|
||||
|
||||
useEffect(() => {
|
||||
const layout = magicNotesLayoutRef.current
|
||||
const updateLayoutWidth = (): void => {
|
||||
const width = layout?.getBoundingClientRect().width || window.innerWidth
|
||||
setMagicNotesLayoutWidth(width)
|
||||
if (width > 720) {
|
||||
setListPaneWidth((current) => {
|
||||
const next = clampListPaneWidth(
|
||||
current,
|
||||
width,
|
||||
liveAiPaneWidthRef.current,
|
||||
aiPaneOpen
|
||||
)
|
||||
liveListPaneWidthRef.current = next
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (width > 800) {
|
||||
setAiPaneWidth((current) => {
|
||||
const next = clampAiPaneWidth(current, width)
|
||||
const next = clampAiPaneWidth(
|
||||
current,
|
||||
width,
|
||||
liveListPaneWidthRef.current,
|
||||
listPaneOpen
|
||||
)
|
||||
liveAiPaneWidthRef.current = next
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
updateLayoutWidth()
|
||||
window.addEventListener('resize', updateLayoutWidth)
|
||||
const observer =
|
||||
layout && typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(updateLayoutWidth)
|
||||
: undefined
|
||||
if (layout && observer) {
|
||||
observer.observe(layout)
|
||||
} else {
|
||||
window.addEventListener('resize', updateLayoutWidth)
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateLayoutWidth)
|
||||
if (!observer) {
|
||||
window.removeEventListener('resize', updateLayoutWidth)
|
||||
}
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, [])
|
||||
}, [aiPaneOpen, listPaneOpen])
|
||||
|
||||
const notifyError = useCallback(
|
||||
(error: unknown): void =>
|
||||
@@ -1136,8 +1416,24 @@ export function MagicNotesWorkspace({
|
||||
.reverse(),
|
||||
[detail]
|
||||
)
|
||||
const aiPaneWidthLimits = getAiPaneWidthLimits(magicNotesLayoutWidth)
|
||||
const canResizeAiPane = aiPaneOpen && magicNotesLayoutWidth > 800
|
||||
const listPaneWidthLimits = getListPaneWidthLimits(
|
||||
magicNotesLayoutWidth,
|
||||
aiPaneWidth,
|
||||
aiPaneOpen
|
||||
)
|
||||
const aiPaneWidthLimits = getAiPaneWidthLimits(
|
||||
magicNotesLayoutWidth,
|
||||
listPaneWidth,
|
||||
listPaneOpen
|
||||
)
|
||||
const canResizeListPane =
|
||||
listPaneOpen &&
|
||||
magicNotesLayoutWidth > 720 &&
|
||||
listPaneWidthLimits.maximum > listPaneWidthLimits.minimum
|
||||
const canResizeAiPane =
|
||||
aiPaneOpen &&
|
||||
magicNotesLayoutWidth > 800 &&
|
||||
aiPaneWidthLimits.maximum > aiPaneWidthLimits.minimum
|
||||
|
||||
const submitCreateNote = async (): Promise<void> => {
|
||||
const title = newTitle.trim()
|
||||
@@ -1419,6 +1715,24 @@ export function MagicNotesWorkspace({
|
||||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
aria-controls="magic-notes-list-pane"
|
||||
aria-expanded={listPaneOpen}
|
||||
className="secondary-button"
|
||||
onClick={() => setListPaneOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
{listPaneOpen ? (
|
||||
<PanelLeftClose aria-hidden="true" size={15} />
|
||||
) : (
|
||||
<PanelLeftOpen aria-hidden="true" size={15} />
|
||||
)}
|
||||
{t(
|
||||
listPaneOpen
|
||||
? 'actions.hideListPane'
|
||||
: 'actions.showListPane'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-controls="magic-notes-ai-pane"
|
||||
aria-expanded={aiPaneOpen}
|
||||
@@ -1497,14 +1811,26 @@ export function MagicNotesWorkspace({
|
||||
ref={magicNotesLayoutRef}
|
||||
aria-busy={Boolean(busy)}
|
||||
className={`magic-notes-layout${
|
||||
listPaneOpen ? '' : ' magic-notes-layout--list-hidden'
|
||||
}${
|
||||
aiPaneOpen ? '' : ' magic-notes-layout--ai-hidden'
|
||||
}${
|
||||
aiPaneResizing && canResizeAiPane
|
||||
(listPaneResizing && canResizeListPane) ||
|
||||
(aiPaneResizing && canResizeAiPane)
|
||||
? ' magic-notes-layout--resizing'
|
||||
: ''
|
||||
}${
|
||||
listPaneResizing && canResizeListPane
|
||||
? ' magic-notes-layout--list-resizing'
|
||||
: ''
|
||||
}${
|
||||
aiPaneResizing && canResizeAiPane
|
||||
? ' magic-notes-layout--ai-resizing'
|
||||
: ''
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
'--magic-notes-list-width': `${listPaneWidth}px`,
|
||||
'--magic-notes-ai-width': `${aiPaneWidth}px`
|
||||
} as React.CSSProperties
|
||||
}
|
||||
@@ -1516,6 +1842,8 @@ export function MagicNotesWorkspace({
|
||||
: 'todos.listLabel'
|
||||
)}
|
||||
className="magic-notes-list-pane"
|
||||
hidden={!listPaneOpen}
|
||||
id="magic-notes-list-pane"
|
||||
>
|
||||
<PageTabs
|
||||
ariaLabel={t('page.contentLabel')}
|
||||
@@ -1772,6 +2100,59 @@ export function MagicNotesWorkspace({
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{listPaneOpen && (
|
||||
<div
|
||||
aria-controls="magic-notes-list-pane"
|
||||
aria-disabled={!canResizeListPane}
|
||||
aria-label={t('accessibility.resizeListPane')}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemax={listPaneWidthLimits.maximum}
|
||||
aria-valuemin={listPaneWidthLimits.minimum}
|
||||
aria-valuenow={listPaneWidth}
|
||||
aria-valuetext={t('accessibility.listPaneWidth', {
|
||||
width: listPaneWidth
|
||||
})}
|
||||
className="magic-notes-list-resize-handle"
|
||||
onKeyDown={resizeListPaneWithKeyboard}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (
|
||||
listResizePointerIdRef.current === event.pointerId
|
||||
) {
|
||||
listResizePointerIdRef.current = undefined
|
||||
setListPaneWidth(liveListPaneWidthRef.current)
|
||||
setListPaneResizing(false)
|
||||
}
|
||||
}}
|
||||
onPointerCancel={finishListPaneResize}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || !canResizeListPane) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
listResizePointerIdRef.current = event.pointerId
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
resizeListPaneFromClientX(event.clientX, true)
|
||||
setListPaneResizing(true)
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (
|
||||
listResizePointerIdRef.current !== event.pointerId
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!canResizeListPane) {
|
||||
finishListPaneResize(event)
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
resizeListPaneFromClientX(event.clientX, false)
|
||||
}}
|
||||
onPointerUp={finishListPaneResize}
|
||||
role="separator"
|
||||
tabIndex={canResizeListPane ? 0 : -1}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section
|
||||
aria-label={t(
|
||||
libraryView === 'notes'
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1400
|
||||
|
||||
@@ -12,11 +12,20 @@ import {
|
||||
ShieldAlert,
|
||||
Upload
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
AssistantSchedule,
|
||||
AssistantTask,
|
||||
AssistantProject,
|
||||
WorkspaceChanges,
|
||||
WorkspaceDirectoryListing,
|
||||
WorkspaceFilePreview
|
||||
@@ -32,6 +41,28 @@ import {
|
||||
findTaskSchedule,
|
||||
TaskScheduleActions
|
||||
} from './TaskScheduleActions'
|
||||
import type {
|
||||
TerminalSnapshot
|
||||
} from '../../shared/terminal-contracts'
|
||||
import {
|
||||
WORKBAR_APP_DEFINITIONS,
|
||||
workbarLayoutPreferencesSchema,
|
||||
type WorkbarAppDefinition,
|
||||
type WorkbarTabInstance,
|
||||
type WorkbarTargetRef
|
||||
} from '../../shared/workbar-contracts'
|
||||
import {
|
||||
DEFAULT_WORKBAR_INSTANCES,
|
||||
WorkbarShell,
|
||||
type WorkbarInstanceCreateRequest
|
||||
} from './WorkbarShell'
|
||||
import type { TerminalAdapter } from './TerminalPanel'
|
||||
import './terminal-panel.css'
|
||||
|
||||
const TerminalPanel = lazy(async () => {
|
||||
const module = await import('./TerminalPanel')
|
||||
return { default: module.TerminalPanel }
|
||||
})
|
||||
|
||||
export type AssistantSidebarTab =
|
||||
| 'tasks'
|
||||
@@ -69,6 +100,7 @@ type RightAssistantSidebarProps = {
|
||||
workspaceChanges?: WorkspaceChanges
|
||||
workspaceProjectId?: string
|
||||
browserState?: BrowserLiveState
|
||||
currentProject?: AssistantProject
|
||||
restoreFocusRef?: { current: HTMLElement | null }
|
||||
onInteractBrowser: () => Promise<void>
|
||||
onStopBrowser: () => Promise<void>
|
||||
@@ -108,6 +140,45 @@ const emptyChangedFiles: WorkspaceChanges['files'] = []
|
||||
const defaultSidebarRatio = 0.3
|
||||
const minimumPaneWidth = 300
|
||||
const keyboardResizeStep = 16
|
||||
const workbarStorageKey = 'goodbuddy.workbar-layout.v1'
|
||||
|
||||
function loadPersistedWorkbarLayout(): ReturnType<
|
||||
typeof workbarLayoutPreferencesSchema.parse
|
||||
> | undefined {
|
||||
try {
|
||||
const value = localStorage.getItem(workbarStorageKey)
|
||||
if (!value || value.length > 100_000) {
|
||||
return undefined
|
||||
}
|
||||
return workbarLayoutPreferencesSchema.parse(JSON.parse(value))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function persistWorkbarLayout(
|
||||
instances: readonly WorkbarTabInstance[],
|
||||
activeInstanceId: string | null,
|
||||
expanded: boolean,
|
||||
widthRatio: number
|
||||
): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
workbarStorageKey,
|
||||
JSON.stringify(
|
||||
workbarLayoutPreferencesSchema.parse({
|
||||
instances,
|
||||
activeInstanceId,
|
||||
expanded,
|
||||
dock: 'right',
|
||||
widthRatio
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
// The in-memory workbar remains usable when browser storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
function getSidebarWidthLimits(layoutWidth: number): {
|
||||
minimum: number
|
||||
@@ -170,6 +241,7 @@ export function RightAssistantSidebar({
|
||||
workspaceChanges,
|
||||
workspaceProjectId,
|
||||
browserState,
|
||||
currentProject,
|
||||
restoreFocusRef,
|
||||
onInteractBrowser,
|
||||
onStopBrowser,
|
||||
@@ -206,10 +278,68 @@ export function RightAssistantSidebar({
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
const initialLayout = useMemo(
|
||||
() => loadPersistedWorkbarLayout(),
|
||||
[]
|
||||
)
|
||||
const localizedAppDefinitions = useMemo(
|
||||
() =>
|
||||
WORKBAR_APP_DEFINITIONS.map((definition) => {
|
||||
const tabDefinition = tabs.find(
|
||||
(item) => item.id === definition.id
|
||||
)
|
||||
return {
|
||||
...definition,
|
||||
label:
|
||||
tabDefinition?.label ??
|
||||
t('sidebar.tabs.terminal.label'),
|
||||
description:
|
||||
tabDefinition?.description ??
|
||||
t('sidebar.tabs.terminal.description')
|
||||
} satisfies WorkbarAppDefinition
|
||||
}),
|
||||
[t, tabs]
|
||||
)
|
||||
const defaultInstances = useMemo(
|
||||
() =>
|
||||
DEFAULT_WORKBAR_INSTANCES.map((instance) => ({
|
||||
...instance,
|
||||
title:
|
||||
localizedAppDefinitions.find(
|
||||
(definition) => definition.id === instance.appId
|
||||
)?.label ?? instance.title
|
||||
})),
|
||||
[localizedAppDefinitions]
|
||||
)
|
||||
const [workbarInstances, setWorkbarInstances] = useState<
|
||||
WorkbarTabInstance[]
|
||||
>(() =>
|
||||
(initialLayout?.instances ?? defaultInstances).map((instance) =>
|
||||
instance.appId === 'terminal'
|
||||
? instance
|
||||
: {
|
||||
...instance,
|
||||
title:
|
||||
localizedAppDefinitions.find(
|
||||
(definition) => definition.id === instance.appId
|
||||
)?.label ?? instance.title
|
||||
}
|
||||
)
|
||||
)
|
||||
const [activeWorkbarInstanceId, setActiveWorkbarInstanceId] =
|
||||
useState<string | null>(
|
||||
() =>
|
||||
initialLayout?.activeInstanceId ??
|
||||
defaultInstances.find((instance) => instance.appId === tab)?.id ??
|
||||
defaultInstances[0]?.id ??
|
||||
null
|
||||
)
|
||||
const [splitLayoutWidth, setSplitLayoutWidth] = useState(
|
||||
window.innerWidth
|
||||
)
|
||||
const [sidebarRatio, setSidebarRatio] = useState(defaultSidebarRatio)
|
||||
const [sidebarRatio, setSidebarRatio] = useState(
|
||||
initialLayout?.widthRatio ?? defaultSidebarRatio
|
||||
)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
const wasOpen = useRef(false)
|
||||
@@ -249,12 +379,41 @@ export function RightAssistantSidebar({
|
||||
'attention' | 'active' | 'paused' | 'finished'
|
||||
>('active')
|
||||
const [actionError, setActionError] = useState('')
|
||||
const [terminalSessionIds, setTerminalSessionIds] = useState<
|
||||
Record<string, string>
|
||||
>({})
|
||||
const [terminalSnapshots, setTerminalSnapshots] = useState<
|
||||
Record<string, TerminalSnapshot>
|
||||
>({})
|
||||
const [terminalCloseConfirmation, setTerminalCloseConfirmation] =
|
||||
useState<{
|
||||
instance: WorkbarTabInstance
|
||||
resolve: (accepted: boolean) => void
|
||||
closing: boolean
|
||||
error?: string
|
||||
}>()
|
||||
const terminalCloseCancelRef = useRef<HTMLButtonElement>(null)
|
||||
const lastExternalTabRef = useRef(tab)
|
||||
const artifactPreview =
|
||||
artifacts.find((artifact) => artifact.id === selectedArtifactId)
|
||||
const currentWorkspacePreview =
|
||||
workspacePreview?.projectId === workspaceProjectId
|
||||
? workspacePreview
|
||||
: undefined
|
||||
const terminalAdapter = useMemo<TerminalAdapter>(
|
||||
() => ({
|
||||
create: (request) => window.goodbuddy.terminal.create(request),
|
||||
write: (request) => window.goodbuddy.terminal.write(request),
|
||||
resize: (request) => window.goodbuddy.terminal.resize(request),
|
||||
close: (request) => window.goodbuddy.terminal.close(request),
|
||||
getSnapshot: (request) =>
|
||||
window.goodbuddy.terminal.getSnapshot(request),
|
||||
ack: (request) => window.goodbuddy.terminal.ack(request),
|
||||
subscribe: (listener) =>
|
||||
window.goodbuddy.terminal.onEvent(listener)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const sidebarWidthLimits = getSidebarWidthLimits(splitLayoutWidth)
|
||||
const canResize =
|
||||
open &&
|
||||
@@ -288,6 +447,209 @@ export function RightAssistantSidebar({
|
||||
[taskFilter, topLevelTasks]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (lastExternalTabRef.current === tab) {
|
||||
return
|
||||
}
|
||||
lastExternalTabRef.current = tab
|
||||
const requested = workbarInstances.find(
|
||||
(instance) => instance.appId === tab
|
||||
)
|
||||
if (requested) {
|
||||
// This effect intentionally mirrors the legacy external tab prop into
|
||||
// the dynamic workbar only when that prop actually changes.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveWorkbarInstanceId(requested.id)
|
||||
}
|
||||
}, [tab, workbarInstances])
|
||||
|
||||
useEffect(() => {
|
||||
persistWorkbarLayout(
|
||||
workbarInstances,
|
||||
activeWorkbarInstanceId,
|
||||
open,
|
||||
sidebarRatio
|
||||
)
|
||||
}, [
|
||||
activeWorkbarInstanceId,
|
||||
open,
|
||||
sidebarRatio,
|
||||
workbarInstances
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalCloseConfirmation && !terminalCloseConfirmation.closing) {
|
||||
terminalCloseCancelRef.current?.focus()
|
||||
}
|
||||
}, [terminalCloseConfirmation])
|
||||
|
||||
const resolveTerminalTarget = useCallback(
|
||||
(): WorkbarTargetRef =>
|
||||
currentProject
|
||||
? { type: 'project', projectId: currentProject.id }
|
||||
: { type: 'local' },
|
||||
[currentProject]
|
||||
)
|
||||
|
||||
const updateActiveWorkbarInstance = useCallback(
|
||||
(instanceId: string): void => {
|
||||
setActiveWorkbarInstanceId(instanceId)
|
||||
const instance = workbarInstances.find(
|
||||
(candidate) => candidate.id === instanceId
|
||||
)
|
||||
if (
|
||||
instance &&
|
||||
instance.appId !== 'terminal'
|
||||
) {
|
||||
onTabChange(instance.appId)
|
||||
}
|
||||
},
|
||||
[onTabChange, workbarInstances]
|
||||
)
|
||||
|
||||
const createWorkbarInstance = useCallback(
|
||||
(request: WorkbarInstanceCreateRequest): void => {
|
||||
const definition = localizedAppDefinitions.find(
|
||||
(candidate) => candidate.id === request.appId
|
||||
)
|
||||
if (!definition) {
|
||||
return
|
||||
}
|
||||
const sameTargetTerminals = workbarInstances.filter(
|
||||
(instance) =>
|
||||
instance.appId === 'terminal' &&
|
||||
JSON.stringify(instance.targetRef) ===
|
||||
JSON.stringify(request.targetRef)
|
||||
).length
|
||||
const instance: WorkbarTabInstance = {
|
||||
id: crypto.randomUUID(),
|
||||
appId: request.appId,
|
||||
title:
|
||||
request.appId === 'terminal'
|
||||
? `${definition.label} ${sameTargetTerminals + 1}`
|
||||
: definition.label,
|
||||
...(request.targetRef
|
||||
? { targetRef: request.targetRef }
|
||||
: {})
|
||||
}
|
||||
setWorkbarInstances((current) => {
|
||||
const afterIndex = request.insertAfterInstanceId
|
||||
? current.findIndex(
|
||||
(candidate) =>
|
||||
candidate.id === request.insertAfterInstanceId
|
||||
)
|
||||
: -1
|
||||
const next = [...current]
|
||||
next.splice(afterIndex >= 0 ? afterIndex + 1 : next.length, 0, instance)
|
||||
return next
|
||||
})
|
||||
setActiveWorkbarInstanceId(instance.id)
|
||||
},
|
||||
[localizedAppDefinitions, workbarInstances]
|
||||
)
|
||||
|
||||
const removeWorkbarInstance = useCallback(
|
||||
(instanceId: string): void => {
|
||||
setWorkbarInstances((current) =>
|
||||
current.filter((instance) => instance.id !== instanceId)
|
||||
)
|
||||
setTerminalSessionIds((current) => {
|
||||
const next = { ...current }
|
||||
delete next[instanceId]
|
||||
return next
|
||||
})
|
||||
setTerminalSnapshots((current) => {
|
||||
const next = { ...current }
|
||||
delete next[instanceId]
|
||||
return next
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const requestCloseWorkbarInstance = useCallback(
|
||||
async (instance: WorkbarTabInstance): Promise<boolean> => {
|
||||
if (instance.appId !== 'terminal') {
|
||||
removeWorkbarInstance(instance.id)
|
||||
return true
|
||||
}
|
||||
const sessionId = terminalSessionIds[instance.id]
|
||||
const snapshot = terminalSnapshots[instance.id]
|
||||
if (
|
||||
sessionId &&
|
||||
(!snapshot ||
|
||||
snapshot.state === 'starting' ||
|
||||
snapshot.state === 'running' ||
|
||||
snapshot.state === 'closing')
|
||||
) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
setTerminalCloseConfirmation({
|
||||
instance,
|
||||
resolve,
|
||||
closing: false
|
||||
})
|
||||
})
|
||||
}
|
||||
if (sessionId) {
|
||||
try {
|
||||
await terminalAdapter.close({ sessionId })
|
||||
} catch {
|
||||
// An already-ended session can still be removed from the workbar.
|
||||
}
|
||||
}
|
||||
removeWorkbarInstance(instance.id)
|
||||
return true
|
||||
},
|
||||
[
|
||||
removeWorkbarInstance,
|
||||
terminalAdapter,
|
||||
terminalSessionIds,
|
||||
terminalSnapshots
|
||||
]
|
||||
)
|
||||
|
||||
const confirmTerminalClose = useCallback(async (): Promise<void> => {
|
||||
const confirmation = terminalCloseConfirmation
|
||||
if (!confirmation || confirmation.closing) {
|
||||
return
|
||||
}
|
||||
const sessionId = terminalSessionIds[confirmation.instance.id]
|
||||
setTerminalCloseConfirmation({
|
||||
...confirmation,
|
||||
closing: true,
|
||||
error: undefined
|
||||
})
|
||||
try {
|
||||
if (sessionId) {
|
||||
await terminalAdapter.close({ sessionId })
|
||||
}
|
||||
removeWorkbarInstance(confirmation.instance.id)
|
||||
setTerminalCloseConfirmation(undefined)
|
||||
confirmation.resolve(true)
|
||||
} catch (reason) {
|
||||
setTerminalCloseConfirmation({
|
||||
...confirmation,
|
||||
closing: false,
|
||||
error:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: t('sidebar.terminal.closeDialog.error')
|
||||
})
|
||||
}
|
||||
}, [
|
||||
removeWorkbarInstance,
|
||||
terminalAdapter,
|
||||
terminalCloseConfirmation,
|
||||
terminalSessionIds,
|
||||
t
|
||||
])
|
||||
|
||||
const cancelTerminalClose = useCallback((): void => {
|
||||
const confirmation = terminalCloseConfirmation
|
||||
setTerminalCloseConfirmation(undefined)
|
||||
confirmation?.resolve(false)
|
||||
}, [terminalCloseConfirmation])
|
||||
|
||||
useEffect(() => {
|
||||
const sidebar = sidebarRef.current
|
||||
const workspace = findWorkspaceSibling(sidebar)
|
||||
@@ -470,36 +832,6 @@ export function RightAssistantSidebar({
|
||||
})
|
||||
}
|
||||
|
||||
const moveTabFocus = (
|
||||
event: React.KeyboardEvent<HTMLButtonElement>,
|
||||
tabId: AssistantSidebarTab
|
||||
): void => {
|
||||
const index = tabs.findIndex((item) => item.id === tabId)
|
||||
const targetIndex =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? tabs.length - 1
|
||||
: event.key === 'ArrowLeft'
|
||||
? (index - 1 + tabs.length) % tabs.length
|
||||
: event.key === 'ArrowRight'
|
||||
? (index + 1) % tabs.length
|
||||
: -1
|
||||
if (targetIndex < 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const target = tabs[targetIndex]
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
setActionError('')
|
||||
onTabChange(target.id)
|
||||
requestAnimationFrame(() => {
|
||||
document.getElementById(`assistant-sidebar-tab-${target.id}`)?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
@@ -573,57 +905,75 @@ export function RightAssistantSidebar({
|
||||
role="separator"
|
||||
tabIndex={canResize ? 0 : -1}
|
||||
/>
|
||||
<nav
|
||||
aria-label={t('sidebar.categoriesAriaLabel')}
|
||||
className="assistant-sidebar__tabs"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
aria-controls="assistant-sidebar-panel"
|
||||
aria-selected={tab === item.id}
|
||||
className={
|
||||
tab === item.id
|
||||
? 'assistant-sidebar__tab assistant-sidebar__tab--active'
|
||||
: 'assistant-sidebar__tab'
|
||||
}
|
||||
id={`assistant-sidebar-tab-${item.id}`}
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActionError('')
|
||||
onTabChange(item.id)
|
||||
}}
|
||||
onKeyDown={(event) => moveTabFocus(event, item.id)}
|
||||
role="tab"
|
||||
tabIndex={tab === item.id ? 0 : -1}
|
||||
title={item.description}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
{item.id === 'tasks' && approvals.length > 0 && (
|
||||
<span
|
||||
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`}
|
||||
className="assistant-sidebar__badge"
|
||||
>
|
||||
{approvals.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div
|
||||
aria-labelledby={`assistant-sidebar-tab-${tab}`}
|
||||
className="assistant-sidebar__body"
|
||||
id="assistant-sidebar-panel"
|
||||
role="tabpanel"
|
||||
>
|
||||
<WorkbarShell
|
||||
activeInstanceId={activeWorkbarInstanceId}
|
||||
appDefinitions={localizedAppDefinitions}
|
||||
instances={workbarInstances}
|
||||
onActiveInstanceChange={updateActiveWorkbarInstance}
|
||||
onCloseInstance={requestCloseWorkbarInstance}
|
||||
onCreateInstance={createWorkbarInstance}
|
||||
onResolveTerminalTarget={resolveTerminalTarget}
|
||||
renderTabAdornment={(instance) =>
|
||||
instance.appId === 'tasks' && approvals.length > 0 ? (
|
||||
<span
|
||||
aria-label={`${t('sidebar.tasks.approvalsTitle')}: ${approvals.length}`}
|
||||
className="assistant-sidebar__badge"
|
||||
>
|
||||
{approvals.length}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
renderPanel={(instance) => (
|
||||
<div className="assistant-sidebar__body">
|
||||
{actionError ? (
|
||||
<p className="settings-error" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
{tab === 'tasks' && (
|
||||
{instance.appId === 'terminal' && instance.targetRef ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<section
|
||||
aria-busy="true"
|
||||
aria-label={t('sidebar.terminal.loading')}
|
||||
className="terminal-panel terminal-panel--loading"
|
||||
>
|
||||
<span>{t('sidebar.terminal.loading')}</span>
|
||||
</section>
|
||||
}
|
||||
>
|
||||
<TerminalPanel
|
||||
adapter={terminalAdapter}
|
||||
onRename={(title) =>
|
||||
setWorkbarInstances((current) =>
|
||||
current.map((candidate) =>
|
||||
candidate.id === instance.id
|
||||
? { ...candidate, title }
|
||||
: candidate
|
||||
)
|
||||
)
|
||||
}
|
||||
onSessionChange={(snapshot) => {
|
||||
setTerminalSessionIds((current) =>
|
||||
current[instance.id] === snapshot.sessionId
|
||||
? current
|
||||
: {
|
||||
...current,
|
||||
[instance.id]: snapshot.sessionId
|
||||
}
|
||||
)
|
||||
setTerminalSnapshots((current) => ({
|
||||
...current,
|
||||
[instance.id]: snapshot
|
||||
}))
|
||||
}}
|
||||
sessionId={terminalSessionIds[instance.id]}
|
||||
target={instance.targetRef}
|
||||
title={instance.title}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
{instance.appId === 'tasks' && (
|
||||
<section className="assistant-sidebar__section">
|
||||
<h3>
|
||||
<ShieldAlert size={15} />
|
||||
@@ -812,7 +1162,7 @@ export function RightAssistantSidebar({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === 'workspace' && (
|
||||
{instance.appId === 'workspace' && (
|
||||
currentWorkspacePreview ? (
|
||||
<section
|
||||
aria-busy={currentWorkspacePreview.state === 'loading'}
|
||||
@@ -934,7 +1284,7 @@ export function RightAssistantSidebar({
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'results' && (
|
||||
{instance.appId === 'results' && (
|
||||
artifactPreview ? (
|
||||
<section className="assistant-sidebar__preview">
|
||||
<header>
|
||||
@@ -1043,7 +1393,7 @@ export function RightAssistantSidebar({
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 'browser' && (
|
||||
{instance.appId === 'browser' && (
|
||||
<section className="assistant-sidebar__browser">
|
||||
<header>
|
||||
<span>
|
||||
@@ -1149,7 +1499,54 @@ export function RightAssistantSidebar({
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{terminalCloseConfirmation ? (
|
||||
<div className="assistant-sidebar__overlay">
|
||||
<section
|
||||
aria-label={t('sidebar.terminal.closeDialog.ariaLabel')}
|
||||
className="assistant-sidebar__terminal-close"
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!terminalCloseConfirmation.closing
|
||||
) {
|
||||
event.preventDefault()
|
||||
cancelTerminalClose()
|
||||
}
|
||||
}}
|
||||
role="alertdialog"
|
||||
>
|
||||
<h2>{t('sidebar.terminal.closeDialog.title')}</h2>
|
||||
<p>{t('sidebar.terminal.closeDialog.description')}</p>
|
||||
{terminalCloseConfirmation.error ? (
|
||||
<p role="alert">{terminalCloseConfirmation.error}</p>
|
||||
) : null}
|
||||
<div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={terminalCloseConfirmation.closing}
|
||||
onClick={cancelTerminalClose}
|
||||
ref={terminalCloseCancelRef}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.terminal.closeDialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="danger-button"
|
||||
disabled={terminalCloseConfirmation.closing}
|
||||
onClick={() => void confirmTerminalClose()}
|
||||
type="button"
|
||||
>
|
||||
{terminalCloseConfirmation.closing
|
||||
? t('sidebar.terminal.closeDialog.closing')
|
||||
: t('sidebar.terminal.closeDialog.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
TerminalEvent,
|
||||
TerminalSnapshot
|
||||
} from '../../shared/terminal-contracts'
|
||||
import {
|
||||
TerminalPanel,
|
||||
resolveTerminalFontFamily,
|
||||
type TerminalAdapter,
|
||||
type TerminalEmulator,
|
||||
type TerminalFactory
|
||||
} from './TerminalPanel'
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: class MockTerminal {} }))
|
||||
vi.mock('@xterm/addon-fit', () => ({ FitAddon: class MockFitAddon {} }))
|
||||
vi.mock('@xterm/addon-search', () => ({
|
||||
SearchAddon: class MockSearchAddon {}
|
||||
}))
|
||||
|
||||
const sessionId = '00000000-0000-4000-8000-000000000201'
|
||||
|
||||
function snapshot(
|
||||
overrides: Partial<TerminalSnapshot> = {}
|
||||
): TerminalSnapshot {
|
||||
return {
|
||||
sessionId,
|
||||
target: { type: 'local' },
|
||||
targetLabel: '本机',
|
||||
title: '终端 · 本机 1',
|
||||
state: 'running',
|
||||
shell: 'PowerShell 7',
|
||||
workingDirectory: 'D:\\workspace',
|
||||
size: { cols: 80, rows: 24 },
|
||||
lastSequence: 0,
|
||||
exit: null,
|
||||
error: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type EmulatorHarness = {
|
||||
factory: TerminalFactory
|
||||
terminal: TerminalEmulator
|
||||
emitInput: (data: string) => void
|
||||
write: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
fit: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function emulatorHarness(): EmulatorHarness {
|
||||
let inputListener: ((data: string) => void) | undefined
|
||||
const write = vi.fn((_: string, callback?: () => void) => callback?.())
|
||||
const dispose = vi.fn()
|
||||
const fit = vi.fn()
|
||||
const terminal: TerminalEmulator = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
open: vi.fn(),
|
||||
onData: vi.fn((listener: (data: string) => void) => {
|
||||
inputListener = listener
|
||||
return { dispose: vi.fn() }
|
||||
}),
|
||||
write,
|
||||
clear: vi.fn(),
|
||||
selectAll: vi.fn(),
|
||||
getSelection: vi.fn(() => 'selected output'),
|
||||
focus: vi.fn(),
|
||||
dispose
|
||||
}
|
||||
return {
|
||||
terminal,
|
||||
write,
|
||||
dispose,
|
||||
fit,
|
||||
emitInput: (data) => inputListener?.(data),
|
||||
factory: () => ({
|
||||
terminal,
|
||||
fitAddon: { fit },
|
||||
searchAddon: { findNext: vi.fn(() => true) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type AdapterHarness = {
|
||||
adapter: TerminalAdapter
|
||||
emit: (event: TerminalEvent) => void
|
||||
}
|
||||
|
||||
function adapterHarness(
|
||||
createResult: TerminalSnapshot = snapshot()
|
||||
): AdapterHarness {
|
||||
let eventListener: ((event: TerminalEvent) => void) | undefined
|
||||
const adapter: TerminalAdapter = {
|
||||
create: vi.fn(async () => createResult),
|
||||
write: vi.fn(async () => undefined),
|
||||
resize: vi.fn(async () => undefined),
|
||||
close: vi.fn(async () =>
|
||||
snapshot({ state: 'exited', exit: { exitCode: 0, signal: null } })
|
||||
),
|
||||
getSnapshot: vi.fn(async () => createResult),
|
||||
ack: vi.fn(async () => undefined),
|
||||
subscribe: vi.fn((listener) => {
|
||||
eventListener = listener
|
||||
return vi.fn()
|
||||
})
|
||||
}
|
||||
return {
|
||||
adapter,
|
||||
emit: (event) => eventListener?.(event)
|
||||
}
|
||||
}
|
||||
|
||||
type ResizeObserverHarness = {
|
||||
trigger: (width?: number, height?: number) => void
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserverHarness
|
||||
|
||||
beforeEach(() => {
|
||||
const disconnect = vi.fn()
|
||||
class MockResizeObserver {
|
||||
private readonly callback: ResizeObserverCallback
|
||||
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.callback = callback
|
||||
resizeObserver = {
|
||||
disconnect,
|
||||
trigger: (width = 800, height = 480) =>
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
contentRect: {
|
||||
width,
|
||||
height
|
||||
} as DOMRectReadOnly
|
||||
} as ResizeObserverEntry
|
||||
],
|
||||
this as unknown as ResizeObserver
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect = disconnect
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', MockResizeObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.documentElement.style.removeProperty('--font-family-mono')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('TerminalPanel', () => {
|
||||
it('creates only after the first valid fitted size and forwards input', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
|
||||
resizeObserver.trigger(0, 0)
|
||||
expect(backend.adapter.create).not.toHaveBeenCalled()
|
||||
resizeObserver.trigger()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledWith({
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
)
|
||||
expect(screen.getByText('PowerShell 7')).toBeInTheDocument()
|
||||
|
||||
emulator.emitInput('Get-Location\r')
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.write).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
data: 'Get-Location\r'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('deduplicates resize, consumes ordered events, and ACKs output', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
resizeObserver.trigger(700, 400)
|
||||
resizeObserver.trigger(600, 300)
|
||||
expect(backend.adapter.resize).not.toHaveBeenCalled()
|
||||
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
type: 'output',
|
||||
data: 'hello'
|
||||
})
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
type: 'output',
|
||||
data: 'duplicate'
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(emulator.write).toHaveBeenCalledWith(
|
||||
'hello',
|
||||
expect.any(Function)
|
||||
)
|
||||
)
|
||||
expect(emulator.write).not.toHaveBeenCalledWith(
|
||||
'duplicate',
|
||||
expect.anything()
|
||||
)
|
||||
Object.defineProperty(emulator.terminal, 'cols', { value: 100 })
|
||||
Object.defineProperty(emulator.terminal, 'rows', { value: 30 })
|
||||
resizeObserver.trigger()
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.resize).toHaveBeenCalledOnce()
|
||||
)
|
||||
expect(backend.adapter.resize).toHaveBeenCalledWith({
|
||||
sessionId,
|
||||
cols: 100,
|
||||
rows: 30
|
||||
})
|
||||
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 2,
|
||||
type: 'state',
|
||||
state: 'interrupted'
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.ack).toHaveBeenLastCalledWith({
|
||||
sessionId,
|
||||
sequence: 2
|
||||
})
|
||||
)
|
||||
expect(screen.getAllByText('连接已中断')).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unsubscribes and destroys the emulator without closing the session', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
const unsubscribe = vi.fn()
|
||||
vi.mocked(backend.adapter.subscribe).mockImplementation((listener) => {
|
||||
void listener
|
||||
return unsubscribe
|
||||
})
|
||||
const view = render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
sessionId={sessionId}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.getSnapshot).toHaveBeenCalledWith({
|
||||
sessionId
|
||||
})
|
||||
)
|
||||
|
||||
view.unmount()
|
||||
expect(unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(emulator.dispose).toHaveBeenCalledOnce()
|
||||
expect(resizeObserver.disconnect).toHaveBeenCalledOnce()
|
||||
expect(backend.adapter.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves session closing to the workbar tab action', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
sessionId={sessionId}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
await screen.findByText('PowerShell 7')
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: '关闭终端会话' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(backend.adapter.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders and ACKs startup events included in the create snapshot sequence', async () => {
|
||||
const backend = adapterHarness(snapshot({ lastSequence: 2 }))
|
||||
const emulator = emulatorHarness()
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
type: 'output',
|
||||
data: 'startup prompt'
|
||||
})
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 2,
|
||||
type: 'state',
|
||||
state: 'running'
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(emulator.write).toHaveBeenCalledWith(
|
||||
'startup prompt',
|
||||
expect.any(Function)
|
||||
)
|
||||
)
|
||||
expect(backend.adapter.ack).toHaveBeenLastCalledWith({
|
||||
sessionId,
|
||||
sequence: 2
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps one emulator when callback identities change', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
const factory = vi.fn(emulator.factory)
|
||||
const firstCallback = vi.fn()
|
||||
const view = render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
onSessionChange={firstCallback}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
const latestCallback = vi.fn()
|
||||
view.rerender(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
onSessionChange={latestCallback}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={factory}
|
||||
/>
|
||||
)
|
||||
backend.emit({
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
type: 'state',
|
||||
state: 'interrupted'
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(latestCallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ state: 'interrupted' })
|
||||
)
|
||||
)
|
||||
expect(factory).toHaveBeenCalledOnce()
|
||||
expect(emulator.dispose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a session whose create result arrives after unmount', async () => {
|
||||
let resolveCreate:
|
||||
| ((value: TerminalSnapshot) => void)
|
||||
| undefined
|
||||
const backend = adapterHarness()
|
||||
vi.mocked(backend.adapter.create).mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveCreate = resolve
|
||||
})
|
||||
)
|
||||
const emulator = emulatorHarness()
|
||||
const view = render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
view.unmount()
|
||||
resolveCreate?.(snapshot())
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.close).toHaveBeenCalledWith({ sessionId })
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the resolved cross-platform monospace font stack', () => {
|
||||
document.documentElement.style.setProperty(
|
||||
'--font-family-mono',
|
||||
'"Test Mono", monospace'
|
||||
)
|
||||
expect(resolveTerminalFontFamily()).toBe(
|
||||
'"Test Mono", monospace'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps creation failures visible and allows retrying', async () => {
|
||||
const backend = adapterHarness()
|
||||
vi.mocked(backend.adapter.create)
|
||||
.mockRejectedValueOnce(new Error('Shell 启动失败'))
|
||||
.mockResolvedValueOnce(snapshot())
|
||||
const emulator = emulatorHarness()
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
resizeObserver.trigger()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Shell 启动失败'
|
||||
)
|
||||
expect(screen.getAllByText('连接失败')).not.toHaveLength(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }))
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.create).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(await screen.findAllByText('已连接')).not.toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,786 @@
|
||||
import { SearchAddon } from '@xterm/addon-search'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import {
|
||||
Clipboard,
|
||||
Copy,
|
||||
Eraser,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import type { DesktopApi } from '../../shared/contracts'
|
||||
import type {
|
||||
TerminalEvent,
|
||||
TerminalSessionId,
|
||||
TerminalSnapshot,
|
||||
TerminalTarget
|
||||
} from '../../shared/terminal-contracts'
|
||||
import './terminal-panel.css'
|
||||
|
||||
export type TerminalAdapter = Omit<DesktopApi['terminal'], 'onEvent'> & {
|
||||
subscribe: DesktopApi['terminal']['onEvent']
|
||||
}
|
||||
|
||||
type Disposable = { dispose: () => void }
|
||||
|
||||
export type TerminalEmulator = {
|
||||
readonly cols: number
|
||||
readonly rows: number
|
||||
open: (element: HTMLElement) => void
|
||||
onData: (listener: (data: string) => void) => Disposable
|
||||
write: (data: string, callback?: () => void) => void
|
||||
clear: () => void
|
||||
selectAll: () => void
|
||||
getSelection: () => string
|
||||
focus: () => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export type TerminalFitAddon = {
|
||||
fit: () => void
|
||||
}
|
||||
|
||||
export type TerminalSearchAddon = {
|
||||
findNext: (term: string) => boolean
|
||||
}
|
||||
|
||||
export type TerminalFactoryResult = {
|
||||
terminal: TerminalEmulator
|
||||
fitAddon: TerminalFitAddon
|
||||
searchAddon: TerminalSearchAddon
|
||||
activateRenderer?: () => void
|
||||
}
|
||||
|
||||
export type TerminalFactory = () => TerminalFactoryResult
|
||||
|
||||
export type TerminalPanelProps = {
|
||||
adapter: TerminalAdapter
|
||||
target: TerminalTarget
|
||||
sessionId?: TerminalSessionId
|
||||
title?: string
|
||||
terminalFactory?: TerminalFactory
|
||||
onSessionChange?: (snapshot: TerminalSnapshot) => void
|
||||
onRename?: (title: string) => void
|
||||
}
|
||||
|
||||
const announcedStates = new Set<TerminalSnapshot['state']>([
|
||||
'running',
|
||||
'exited',
|
||||
'interrupted',
|
||||
'failed'
|
||||
])
|
||||
|
||||
const fallbackTerminalFontFamily = [
|
||||
'"Cascadia Mono"',
|
||||
'"Cascadia Code"',
|
||||
'"SFMono-Regular"',
|
||||
'Menlo',
|
||||
'Monaco',
|
||||
'Consolas',
|
||||
'"Noto Sans Mono"',
|
||||
'"DejaVu Sans Mono"',
|
||||
'"Liberation Mono"',
|
||||
'monospace'
|
||||
].join(', ')
|
||||
|
||||
export function resolveTerminalFontFamily(): string {
|
||||
const configured = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--font-family-mono')
|
||||
.trim()
|
||||
return configured || fallbackTerminalFontFamily
|
||||
}
|
||||
|
||||
function createDefaultTerminal(): TerminalFactoryResult {
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
scrollback: 10_000,
|
||||
fontFamily: resolveTerminalFontFamily(),
|
||||
fontSize: 13,
|
||||
rescaleOverlappingGlyphs: true
|
||||
})
|
||||
const fitAddon = new FitAddon()
|
||||
const searchAddon = new SearchAddon()
|
||||
terminal.loadAddon(fitAddon)
|
||||
terminal.loadAddon(searchAddon)
|
||||
return {
|
||||
terminal,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
activateRenderer: () => {
|
||||
const webglAddon = new WebglAddon()
|
||||
const contextLossSubscription = webglAddon.onContextLoss(() => {
|
||||
contextLossSubscription.dispose()
|
||||
webglAddon.dispose()
|
||||
})
|
||||
try {
|
||||
terminal.loadAddon(webglAddon)
|
||||
} catch {
|
||||
contextLossSubscription.dispose()
|
||||
webglAddon.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function failureMessage(reason: unknown, fallback: string): string {
|
||||
return reason instanceof Error && reason.message
|
||||
? reason.message
|
||||
: fallback
|
||||
}
|
||||
|
||||
function isValidSize(
|
||||
cols: number,
|
||||
rows: number
|
||||
): boolean {
|
||||
return cols >= 2 && rows >= 1
|
||||
}
|
||||
|
||||
export function TerminalPanel({
|
||||
adapter,
|
||||
target,
|
||||
sessionId,
|
||||
title,
|
||||
terminalFactory = createDefaultTerminal,
|
||||
onSessionChange,
|
||||
onRename
|
||||
}: TerminalPanelProps): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const searchInputId = useId()
|
||||
const titleInputId = useId()
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const emulatorRef = useRef<TerminalEmulator | undefined>(undefined)
|
||||
const fitAddonRef = useRef<TerminalFitAddon | undefined>(undefined)
|
||||
const searchAddonRef = useRef<TerminalSearchAddon | undefined>(
|
||||
undefined
|
||||
)
|
||||
const activeSessionRef = useRef<TerminalSessionId | undefined>(
|
||||
undefined
|
||||
)
|
||||
const snapshotRef = useRef<TerminalSnapshot | undefined>(undefined)
|
||||
const mountedRef = useRef(false)
|
||||
const acceptedSequenceRef = useRef(0)
|
||||
const eventQueueRef = useRef(Promise.resolve())
|
||||
const lastSizeRef = useRef<
|
||||
{ cols: number; rows: number } | undefined
|
||||
>(undefined)
|
||||
const operationGenerationRef = useRef(0)
|
||||
const onSessionChangeRef = useRef(onSessionChange)
|
||||
const onRenameRef = useRef(onRename)
|
||||
const translationRef = useRef(t)
|
||||
const [snapshot, setSnapshot] = useState<
|
||||
TerminalSnapshot | undefined
|
||||
>(undefined)
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [draftTitle, setDraftTitle] = useState(title ?? '')
|
||||
const [hasMeasuredSize, setHasMeasuredSize] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
onSessionChangeRef.current = onSessionChange
|
||||
onRenameRef.current = onRename
|
||||
translationRef.current = t
|
||||
}, [onRename, onSessionChange, t])
|
||||
|
||||
const publishSnapshot = useCallback((next: TerminalSnapshot): void => {
|
||||
snapshotRef.current = next
|
||||
setSnapshot(next)
|
||||
onSessionChangeRef.current?.(next)
|
||||
if (announcedStates.has(next.state)) {
|
||||
setAnnouncement(
|
||||
translationRef.current(
|
||||
`sidebar.terminal.states.${next.state}`
|
||||
)
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const consumeEvent = useCallback(
|
||||
async (event: TerminalEvent): Promise<void> => {
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
event.sessionId !== activeSessionRef.current ||
|
||||
event.sequence <= acceptedSequenceRef.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
acceptedSequenceRef.current = event.sequence
|
||||
|
||||
if (event.type === 'output') {
|
||||
await new Promise<void>((resolve) => {
|
||||
emulatorRef.current?.write(event.data, resolve)
|
||||
if (!emulatorRef.current) {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const current = snapshotRef.current
|
||||
if (current) {
|
||||
if (event.type === 'state') {
|
||||
publishSnapshot({ ...current, state: event.state })
|
||||
} else if (event.type === 'exit') {
|
||||
publishSnapshot({
|
||||
...current,
|
||||
state: 'exited',
|
||||
exit: event.exit,
|
||||
error: null
|
||||
})
|
||||
} else {
|
||||
publishSnapshot({
|
||||
...current,
|
||||
state: 'failed',
|
||||
error: event.error,
|
||||
exit: null
|
||||
})
|
||||
setError(event.error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mountedRef.current) {
|
||||
try {
|
||||
await adapter.ack({
|
||||
sessionId: event.sessionId,
|
||||
sequence: event.sequence
|
||||
})
|
||||
} catch (reason) {
|
||||
if (mountedRef.current) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.ack')
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[adapter, publishSnapshot]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
const {
|
||||
terminal,
|
||||
fitAddon,
|
||||
searchAddon,
|
||||
activateRenderer
|
||||
} = terminalFactory()
|
||||
emulatorRef.current = terminal
|
||||
fitAddonRef.current = fitAddon
|
||||
searchAddonRef.current = searchAddon
|
||||
if (hostRef.current) {
|
||||
terminal.open(hostRef.current)
|
||||
activateRenderer?.()
|
||||
}
|
||||
|
||||
const inputSubscription = terminal.onData((data) => {
|
||||
const current = snapshotRef.current
|
||||
if (!current || current.state !== 'running') {
|
||||
return
|
||||
}
|
||||
void adapter
|
||||
.write({ sessionId: current.sessionId, data })
|
||||
.catch((reason: unknown) => {
|
||||
if (mountedRef.current) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.write')
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
const unsubscribe = adapter.subscribe((event) => {
|
||||
eventQueueRef.current = eventQueueRef.current.then(
|
||||
() => consumeEvent(event),
|
||||
() => consumeEvent(event)
|
||||
)
|
||||
})
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
operationGenerationRef.current += 1
|
||||
unsubscribe()
|
||||
inputSubscription.dispose()
|
||||
terminal.dispose()
|
||||
emulatorRef.current = undefined
|
||||
fitAddonRef.current = undefined
|
||||
searchAddonRef.current = undefined
|
||||
}
|
||||
}, [adapter, consumeEvent, terminalFactory])
|
||||
|
||||
const attachSession = useCallback(
|
||||
async (
|
||||
id: TerminalSessionId,
|
||||
generation: number
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const next = await adapter.getSnapshot({ sessionId: id })
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
operationGenerationRef.current !== generation
|
||||
) {
|
||||
return
|
||||
}
|
||||
activeSessionRef.current = next.sessionId
|
||||
acceptedSequenceRef.current = next.lastSequence
|
||||
publishSnapshot(next)
|
||||
setError(next.error?.message)
|
||||
emulatorRef.current?.focus()
|
||||
} catch (reason) {
|
||||
if (
|
||||
mountedRef.current &&
|
||||
operationGenerationRef.current === generation
|
||||
) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.snapshot')
|
||||
)
|
||||
)
|
||||
setAnnouncement(
|
||||
translationRef.current('sidebar.terminal.states.failed')
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[adapter, publishSnapshot]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
const generation = ++operationGenerationRef.current
|
||||
void attachSession(sessionId, generation)
|
||||
}, [attachSession, sessionId])
|
||||
|
||||
const createSession = useCallback(
|
||||
async (cols: number, rows: number): Promise<void> => {
|
||||
const generation = ++operationGenerationRef.current
|
||||
setError(undefined)
|
||||
try {
|
||||
const next = await adapter.create({ target, cols, rows })
|
||||
if (
|
||||
!mountedRef.current ||
|
||||
operationGenerationRef.current !== generation
|
||||
) {
|
||||
try {
|
||||
await adapter.close({ sessionId: next.sessionId })
|
||||
} catch {
|
||||
// The owning tab is gone; Main shutdown cleanup remains the fallback.
|
||||
}
|
||||
return
|
||||
}
|
||||
activeSessionRef.current = next.sessionId
|
||||
acceptedSequenceRef.current = 0
|
||||
publishSnapshot(next)
|
||||
setError(next.error?.message)
|
||||
emulatorRef.current?.focus()
|
||||
} catch (reason) {
|
||||
if (
|
||||
mountedRef.current &&
|
||||
operationGenerationRef.current === generation
|
||||
) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.create')
|
||||
)
|
||||
)
|
||||
setAnnouncement(
|
||||
translationRef.current('sidebar.terminal.states.failed')
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[adapter, publishSnapshot, target]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (!host) {
|
||||
return
|
||||
}
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (
|
||||
!entry ||
|
||||
entry.contentRect.width <= 0 ||
|
||||
entry.contentRect.height <= 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
fitAddonRef.current?.fit()
|
||||
const emulator = emulatorRef.current
|
||||
if (
|
||||
!emulator ||
|
||||
!isValidSize(emulator.cols, emulator.rows)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const size = { cols: emulator.cols, rows: emulator.rows }
|
||||
if (
|
||||
lastSizeRef.current?.cols === size.cols &&
|
||||
lastSizeRef.current.rows === size.rows
|
||||
) {
|
||||
return
|
||||
}
|
||||
lastSizeRef.current = size
|
||||
setHasMeasuredSize(true)
|
||||
|
||||
const current = snapshotRef.current
|
||||
if (!current && !sessionId) {
|
||||
void createSession(size.cols, size.rows)
|
||||
return
|
||||
}
|
||||
if (current && current.state === 'running') {
|
||||
void adapter
|
||||
.resize({ sessionId: current.sessionId, ...size })
|
||||
.catch((reason: unknown) => {
|
||||
if (mountedRef.current) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current(
|
||||
'sidebar.terminal.errors.resize'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
observer.observe(host)
|
||||
return () => observer.disconnect()
|
||||
}, [adapter, createSession, sessionId])
|
||||
|
||||
const runSearch = (): void => {
|
||||
if (searchTerm) {
|
||||
searchAddonRef.current?.findNext(searchTerm)
|
||||
emulatorRef.current?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const copySelection = async (): Promise<void> => {
|
||||
const value = emulatorRef.current?.getSelection() ?? ''
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} catch (reason) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.copy')
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const paste = async (): Promise<void> => {
|
||||
const current = snapshotRef.current
|
||||
if (!current || current.state !== 'running') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await navigator.clipboard.readText()
|
||||
if (data) {
|
||||
await adapter.write({ sessionId: current.sessionId, data })
|
||||
}
|
||||
emulatorRef.current?.focus()
|
||||
} catch (reason) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current('sidebar.terminal.errors.paste')
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const reopenSession = async (): Promise<void> => {
|
||||
const size = lastSizeRef.current
|
||||
if (!size) {
|
||||
return
|
||||
}
|
||||
const current = snapshotRef.current
|
||||
if (current) {
|
||||
try {
|
||||
await adapter.close({ sessionId: current.sessionId })
|
||||
} catch (reason) {
|
||||
if (mountedRef.current) {
|
||||
setError(
|
||||
failureMessage(
|
||||
reason,
|
||||
translationRef.current(
|
||||
'sidebar.terminal.errors.closePrevious'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
emulatorRef.current?.clear()
|
||||
activeSessionRef.current = undefined
|
||||
acceptedSequenceRef.current = 0
|
||||
snapshotRef.current = undefined
|
||||
setSnapshot(undefined)
|
||||
setError(undefined)
|
||||
await createSession(size.cols, size.rows)
|
||||
}
|
||||
|
||||
const submitRename = (): void => {
|
||||
const nextTitle = draftTitle.trim()
|
||||
if (!nextTitle) {
|
||||
return
|
||||
}
|
||||
onRenameRef.current?.(nextTitle)
|
||||
setRenaming(false)
|
||||
emulatorRef.current?.focus()
|
||||
}
|
||||
|
||||
const canReopen =
|
||||
snapshot?.state === 'exited' ||
|
||||
snapshot?.state === 'interrupted' ||
|
||||
snapshot?.state === 'failed'
|
||||
const location =
|
||||
snapshot?.targetLabel ??
|
||||
(target.type === 'local'
|
||||
? t('sidebar.terminal.location.local')
|
||||
: t('sidebar.terminal.location.target'))
|
||||
|
||||
return (
|
||||
<section
|
||||
className="terminal-panel"
|
||||
aria-label={t('sidebar.terminal.ariaLabel', {
|
||||
title: title ?? location
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="terminal-panel__toolbar"
|
||||
role="toolbar"
|
||||
aria-label={t('sidebar.terminal.toolbar.ariaLabel')}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.search')}
|
||||
aria-label={t('sidebar.terminal.toolbar.search')}
|
||||
aria-pressed={searchOpen}
|
||||
onClick={() => setSearchOpen((value) => !value)}
|
||||
>
|
||||
<Search aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.copy')}
|
||||
aria-label={t('sidebar.terminal.toolbar.copy')}
|
||||
onClick={() => void copySelection()}
|
||||
>
|
||||
<Copy aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.paste')}
|
||||
aria-label={t('sidebar.terminal.toolbar.paste')}
|
||||
disabled={snapshot?.state !== 'running'}
|
||||
onClick={() => void paste()}
|
||||
>
|
||||
<Clipboard aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.selectAll')}
|
||||
aria-label={t('sidebar.terminal.toolbar.selectAllAriaLabel')}
|
||||
onClick={() => emulatorRef.current?.selectAll()}
|
||||
>
|
||||
{t('sidebar.terminal.toolbar.selectAll')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.clear')}
|
||||
aria-label={t('sidebar.terminal.toolbar.clear')}
|
||||
onClick={() => {
|
||||
emulatorRef.current?.clear()
|
||||
emulatorRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<Eraser aria-hidden="true" />
|
||||
</button>
|
||||
{onRename ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t('sidebar.terminal.toolbar.rename')}
|
||||
aria-label={t('sidebar.terminal.toolbar.rename')}
|
||||
aria-pressed={renaming}
|
||||
onClick={() => {
|
||||
setDraftTitle(title ?? snapshot?.title ?? '')
|
||||
setRenaming(true)
|
||||
}}
|
||||
>
|
||||
<Pencil aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
<span className="terminal-panel__toolbar-spacer" />
|
||||
{canReopen ? (
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-panel__reopen"
|
||||
onClick={() => void reopenSession()}
|
||||
>
|
||||
<RefreshCw aria-hidden="true" />
|
||||
{snapshot.state === 'interrupted'
|
||||
? t('sidebar.terminal.reopen.interrupted')
|
||||
: t('sidebar.terminal.reopen.default')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{searchOpen ? (
|
||||
<form
|
||||
className="terminal-panel__inline-form"
|
||||
role="search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
runSearch()
|
||||
}}
|
||||
>
|
||||
<label htmlFor={searchInputId}>
|
||||
{t('sidebar.terminal.search.label')}
|
||||
</label>
|
||||
<input
|
||||
id={searchInputId}
|
||||
autoFocus
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setSearchOpen(false)
|
||||
emulatorRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit">
|
||||
{t('sidebar.terminal.search.next')}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{renaming ? (
|
||||
<form
|
||||
className="terminal-panel__inline-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
submitRename()
|
||||
}}
|
||||
>
|
||||
<label htmlFor={titleInputId}>
|
||||
{t('sidebar.terminal.rename.label')}
|
||||
</label>
|
||||
<input
|
||||
id={titleInputId}
|
||||
autoFocus
|
||||
value={draftTitle}
|
||||
onChange={(event) => setDraftTitle(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setRenaming(false)
|
||||
emulatorRef.current?.focus()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit" disabled={!draftTitle.trim()}>
|
||||
{t('sidebar.terminal.rename.save')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setRenaming(false)}>
|
||||
{t('sidebar.terminal.rename.cancel')}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="terminal-panel__error" role="alert">
|
||||
<span>{error}</span>
|
||||
{!snapshot ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasMeasuredSize}
|
||||
onClick={() => void reopenSession()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="terminal-panel__emulator"
|
||||
aria-label={t('sidebar.terminal.status.emulatorAriaLabel')}
|
||||
/>
|
||||
|
||||
<footer
|
||||
className="terminal-panel__status"
|
||||
aria-label={t('sidebar.terminal.status.ariaLabel')}
|
||||
>
|
||||
<span title={location}>{location}</span>
|
||||
<span
|
||||
title={
|
||||
snapshot?.workingDirectory ??
|
||||
t('sidebar.terminal.status.readingDirectory')
|
||||
}
|
||||
>
|
||||
{snapshot?.workingDirectory ??
|
||||
t('sidebar.terminal.status.readingDirectory')}
|
||||
</span>
|
||||
<span
|
||||
title={
|
||||
snapshot?.shell ??
|
||||
t('sidebar.terminal.status.readingShell')
|
||||
}
|
||||
>
|
||||
{snapshot?.shell ??
|
||||
t('sidebar.terminal.status.readingShell')}
|
||||
</span>
|
||||
<strong
|
||||
data-state={snapshot?.state ?? (error ? 'failed' : 'starting')}
|
||||
>
|
||||
{snapshot
|
||||
? t(`sidebar.terminal.states.${snapshot.state}`)
|
||||
: error
|
||||
? t('sidebar.terminal.states.failed')
|
||||
: t('sidebar.terminal.states.creating')}
|
||||
{snapshot?.state === 'exited' && snapshot.exit?.exitCode !== null
|
||||
? t('sidebar.terminal.status.exitCode', {
|
||||
code: snapshot.exit?.exitCode
|
||||
})
|
||||
: ''}
|
||||
</strong>
|
||||
</footer>
|
||||
<div className="terminal-panel__announcement" aria-live="polite">
|
||||
{announcement}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within
|
||||
} from '@testing-library/react'
|
||||
import {
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
WorkbarTabInstance,
|
||||
WorkbarTargetRef
|
||||
} from '../../shared/workbar-contracts'
|
||||
import {
|
||||
DEFAULT_WORKBAR_INSTANCES,
|
||||
WorkbarShell,
|
||||
type WorkbarInstanceCreateRequest
|
||||
} from './WorkbarShell'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const terminalOne: WorkbarTabInstance = {
|
||||
id: '20000000-0000-4000-8000-000000000001',
|
||||
appId: 'terminal',
|
||||
title: '终端 · 本机 1',
|
||||
targetRef: { type: 'local' }
|
||||
}
|
||||
|
||||
function ControlledShell({
|
||||
initialInstances = DEFAULT_WORKBAR_INSTANCES,
|
||||
onCreate = vi.fn(),
|
||||
onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
|
||||
type: 'local'
|
||||
}))
|
||||
}: {
|
||||
initialInstances?: readonly WorkbarTabInstance[]
|
||||
onCreate?: (request: WorkbarInstanceCreateRequest) => void
|
||||
onTerminalTarget?: () =>
|
||||
| WorkbarTargetRef
|
||||
| Promise<WorkbarTargetRef>
|
||||
}): ReactNode {
|
||||
const [instances, setInstances] = useState(initialInstances)
|
||||
const [activeId, setActiveId] = useState(
|
||||
initialInstances[0]?.id ?? null
|
||||
)
|
||||
|
||||
return (
|
||||
<WorkbarShell
|
||||
activeInstanceId={activeId}
|
||||
instances={instances}
|
||||
onActiveInstanceChange={setActiveId}
|
||||
onCloseInstance={(instance) =>
|
||||
setInstances((current) =>
|
||||
current.filter((candidate) => candidate.id !== instance.id)
|
||||
)
|
||||
}
|
||||
onCreateInstance={onCreate}
|
||||
onResolveTerminalTarget={onTerminalTarget}
|
||||
renderPanel={(instance) => <p>{instance.title}内容</p>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkbarShell', () => {
|
||||
it('provides four default single-instance tabs and keeps add outside the tablist', () => {
|
||||
render(<ControlledShell />)
|
||||
|
||||
const tablist = screen.getByRole('tablist', {
|
||||
name: '已打开的工作栏应用'
|
||||
})
|
||||
expect(within(tablist).getAllByRole('tab')).toHaveLength(4)
|
||||
expect(
|
||||
within(tablist).queryByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开工作栏应用' })
|
||||
).toBeVisible()
|
||||
expect(tablist.parentElement).toHaveClass(
|
||||
'workbar-shell__tab-scroll'
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '任务中心' }).parentElement
|
||||
).toHaveClass('workbar-shell__tab-item--active')
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '工作区' }).parentElement
|
||||
).not.toHaveClass('workbar-shell__tab-item--active')
|
||||
})
|
||||
|
||||
it('opens the catalog, focuses its first choice, and restores add-button focus on Escape', async () => {
|
||||
render(<ControlledShell />)
|
||||
const addButton = screen.getByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
|
||||
fireEvent.click(addButton)
|
||||
const firstChoice = screen
|
||||
.getByText('任务中心', { selector: 'strong' })
|
||||
.closest('button')!
|
||||
await waitFor(() => expect(firstChoice).toHaveFocus())
|
||||
expect(
|
||||
screen.getByRole('heading', { name: '新建工作栏应用' })
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(firstChoice, { key: 'Escape' })
|
||||
await waitFor(() => expect(addButton).toHaveFocus())
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: '新建工作栏应用' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '任务中心' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('focuses an existing single instance instead of requesting a duplicate', async () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<ControlledShell onCreate={onCreate} />)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '打开工作栏应用' })
|
||||
)
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getByText('工作区', { selector: 'strong' })
|
||||
.closest('button')!
|
||||
)
|
||||
|
||||
const workspaceTab = screen.getByRole('tab', {
|
||||
name: '工作区'
|
||||
})
|
||||
await waitFor(() => expect(workspaceTab).toHaveFocus())
|
||||
expect(workspaceTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(onCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves the current terminal target for every multiple-instance creation', async () => {
|
||||
const onCreate = vi.fn()
|
||||
const onTerminalTarget = vi.fn((): WorkbarTargetRef => ({
|
||||
type: 'local'
|
||||
}))
|
||||
render(
|
||||
<ControlledShell
|
||||
initialInstances={[...DEFAULT_WORKBAR_INSTANCES, terminalOne]}
|
||||
onCreate={onCreate}
|
||||
onTerminalTarget={onTerminalTarget}
|
||||
/>
|
||||
)
|
||||
|
||||
for (let count = 1; count <= 2; count += 1) {
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /^终端/u })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(onCreate).toHaveBeenCalledTimes(count)
|
||||
)
|
||||
}
|
||||
|
||||
expect(onTerminalTarget).toHaveBeenCalledTimes(2)
|
||||
expect(onCreate).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
appId: 'terminal',
|
||||
targetRef: { type: 'local' }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('supports roving Arrow, Home, and End navigation', () => {
|
||||
render(<ControlledShell />)
|
||||
const tasks = screen.getByRole('tab', { name: '任务中心' })
|
||||
|
||||
tasks.focus()
|
||||
fireEvent.keyDown(tasks, { key: 'ArrowRight' })
|
||||
const workspace = screen.getByRole('tab', { name: '工作区' })
|
||||
expect(workspace).toHaveFocus()
|
||||
expect(workspace).toHaveAttribute('tabindex', '0')
|
||||
|
||||
fireEvent.keyDown(workspace, { key: 'End' })
|
||||
const results = screen.getByRole('tab', { name: '成果' })
|
||||
expect(results).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(results, { key: 'Home' })
|
||||
expect(tasks).toHaveFocus()
|
||||
})
|
||||
|
||||
it('closes the active tab, activates its right neighbor, and restores focus', async () => {
|
||||
render(<ControlledShell />)
|
||||
const workspace = screen.getByRole('tab', { name: '工作区' })
|
||||
fireEvent.click(workspace)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭工作区' })
|
||||
)
|
||||
|
||||
const browser = screen.getByRole('tab', { name: '浏览器' })
|
||||
await waitFor(() => expect(browser).toHaveFocus())
|
||||
expect(browser).toHaveAttribute('aria-selected', 'true')
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: '工作区' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the current active tab focused when a background tab closes', async () => {
|
||||
render(<ControlledShell />)
|
||||
const tasks = screen.getByRole('tab', { name: '任务中心' })
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭成果' })
|
||||
)
|
||||
|
||||
await waitFor(() => expect(tasks).toHaveFocus())
|
||||
expect(tasks).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('shows the empty state while preserving the add command after the last close', async () => {
|
||||
render(
|
||||
<ControlledShell initialInstances={[DEFAULT_WORKBAR_INSTANCES[0]!]} />
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭任务中心' })
|
||||
)
|
||||
|
||||
const addButton = screen.getByRole('button', {
|
||||
name: '打开工作栏应用'
|
||||
})
|
||||
await waitFor(() => expect(addButton).toHaveFocus())
|
||||
expect(
|
||||
screen.getByText('还没有打开的工作栏应用')
|
||||
).toBeInTheDocument()
|
||||
expect(addButton).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,555 @@
|
||||
import {
|
||||
ClipboardCheck,
|
||||
FileOutput,
|
||||
FolderTree,
|
||||
Globe2,
|
||||
SquareTerminal,
|
||||
X,
|
||||
type LucideIcon
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
WORKBAR_APP_DEFINITIONS,
|
||||
WORKBAR_LIMITS,
|
||||
type WorkbarAppDefinition,
|
||||
type WorkbarAppId,
|
||||
type WorkbarTabInstance,
|
||||
type WorkbarTargetRef
|
||||
} from '../../shared/workbar-contracts'
|
||||
|
||||
const WORKBAR_APP_ICONS: Record<WorkbarAppId, LucideIcon> = {
|
||||
tasks: ClipboardCheck,
|
||||
workspace: FolderTree,
|
||||
browser: Globe2,
|
||||
results: FileOutput,
|
||||
terminal: SquareTerminal
|
||||
}
|
||||
|
||||
const DEFAULT_INSTANCE_IDS = {
|
||||
tasks: '10000000-0000-4000-8000-000000000001',
|
||||
workspace: '10000000-0000-4000-8000-000000000002',
|
||||
browser: '10000000-0000-4000-8000-000000000003',
|
||||
results: '10000000-0000-4000-8000-000000000004'
|
||||
} as const
|
||||
|
||||
export const DEFAULT_WORKBAR_INSTANCES: readonly WorkbarTabInstance[] =
|
||||
WORKBAR_APP_DEFINITIONS.filter(
|
||||
(definition) => definition.defaultOpen
|
||||
).map((definition) => ({
|
||||
id: DEFAULT_INSTANCE_IDS[definition.id],
|
||||
appId: definition.id,
|
||||
title: definition.label
|
||||
}))
|
||||
|
||||
export type WorkbarInstanceCreateRequest = {
|
||||
appId: WorkbarAppId
|
||||
insertAfterInstanceId: string | null
|
||||
targetRef?: WorkbarTargetRef
|
||||
}
|
||||
|
||||
export type WorkbarShellProps = {
|
||||
instances: readonly WorkbarTabInstance[]
|
||||
activeInstanceId: string | null
|
||||
renderPanel: (instance: WorkbarTabInstance) => ReactNode
|
||||
renderTabAdornment?: (instance: WorkbarTabInstance) => ReactNode
|
||||
onActiveInstanceChange: (instanceId: string) => void
|
||||
onCreateInstance: (request: WorkbarInstanceCreateRequest) => void
|
||||
onCloseInstance: (
|
||||
instance: WorkbarTabInstance
|
||||
) => boolean | void | Promise<boolean | void>
|
||||
onResolveTerminalTarget: () =>
|
||||
| WorkbarTargetRef
|
||||
| Promise<WorkbarTargetRef>
|
||||
appDefinitions?: readonly WorkbarAppDefinition[]
|
||||
onResolveUnavailableApp?: (definition: WorkbarAppDefinition) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function joinClassNames(...values: (string | false | undefined)[]): string {
|
||||
return values.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function adjacentIndex(
|
||||
key: string,
|
||||
currentIndex: number,
|
||||
itemCount: number
|
||||
): number | undefined {
|
||||
if (key === 'Home') {
|
||||
return 0
|
||||
}
|
||||
if (key === 'End') {
|
||||
return itemCount - 1
|
||||
}
|
||||
if (key === 'ArrowRight') {
|
||||
return (currentIndex + 1) % itemCount
|
||||
}
|
||||
if (key === 'ArrowLeft') {
|
||||
return (currentIndex - 1 + itemCount) % itemCount
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function WorkbarShell({
|
||||
activeInstanceId,
|
||||
appDefinitions = WORKBAR_APP_DEFINITIONS,
|
||||
className,
|
||||
instances,
|
||||
onActiveInstanceChange,
|
||||
onCloseInstance,
|
||||
onCreateInstance,
|
||||
onResolveUnavailableApp,
|
||||
onResolveTerminalTarget,
|
||||
renderPanel,
|
||||
renderTabAdornment
|
||||
}: WorkbarShellProps): React.JSX.Element {
|
||||
const { t } = useTranslation('workspace')
|
||||
const idPrefix = useId()
|
||||
const addButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const catalogRef = useRef<HTMLDivElement>(null)
|
||||
const returnInstanceIdRef = useRef<string | null>(null)
|
||||
const pendingTabFocusRef = useRef<string | null | undefined>(undefined)
|
||||
const focusCreatedInstanceRef = useRef(false)
|
||||
const [catalogOpen, setCatalogOpen] = useState(false)
|
||||
const [creatingAppId, setCreatingAppId] =
|
||||
useState<WorkbarAppId | null>(null)
|
||||
const [targetError, setTargetError] = useState<string | null>(null)
|
||||
|
||||
const activeInstance =
|
||||
instances.find((instance) => instance.id === activeInstanceId) ??
|
||||
null
|
||||
|
||||
const tabId = useCallback(
|
||||
(instanceId: string): string =>
|
||||
`${idPrefix}-tab-${instanceId}`,
|
||||
[idPrefix]
|
||||
)
|
||||
const panelId = (instanceId: string): string =>
|
||||
`${idPrefix}-panel-${instanceId}`
|
||||
|
||||
useEffect(() => {
|
||||
if (!catalogOpen) {
|
||||
return
|
||||
}
|
||||
const firstAvailable =
|
||||
catalogRef.current?.querySelector<HTMLButtonElement>(
|
||||
'[data-workbar-app-choice]:not(:disabled)'
|
||||
)
|
||||
firstAvailable?.focus()
|
||||
}, [catalogOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
focusCreatedInstanceRef.current &&
|
||||
!catalogOpen &&
|
||||
activeInstanceId
|
||||
) {
|
||||
const activeTab = document.getElementById(tabId(activeInstanceId))
|
||||
if (activeTab instanceof HTMLButtonElement) {
|
||||
activeTab.focus()
|
||||
focusCreatedInstanceRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const pendingId = pendingTabFocusRef.current
|
||||
if (pendingId === undefined) {
|
||||
return
|
||||
}
|
||||
if (pendingId === null) {
|
||||
addButtonRef.current?.focus()
|
||||
pendingTabFocusRef.current = undefined
|
||||
return
|
||||
}
|
||||
const tab = document.getElementById(tabId(pendingId))
|
||||
if (tab instanceof HTMLButtonElement) {
|
||||
tab.focus()
|
||||
pendingTabFocusRef.current = undefined
|
||||
}
|
||||
}, [activeInstanceId, catalogOpen, instances, tabId])
|
||||
|
||||
const focusAndActivate = (instanceId: string): void => {
|
||||
onActiveInstanceChange(instanceId)
|
||||
const tab = document.getElementById(tabId(instanceId))
|
||||
if (tab instanceof HTMLButtonElement) {
|
||||
tab.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const closeCatalog = (restoreToAddButton: boolean): void => {
|
||||
setCatalogOpen(false)
|
||||
setCreatingAppId(null)
|
||||
setTargetError(null)
|
||||
const returnInstanceId = returnInstanceIdRef.current
|
||||
if (
|
||||
returnInstanceId &&
|
||||
instances.some((instance) => instance.id === returnInstanceId)
|
||||
) {
|
||||
onActiveInstanceChange(returnInstanceId)
|
||||
}
|
||||
if (restoreToAddButton) {
|
||||
queueMicrotask(() => addButtonRef.current?.focus())
|
||||
}
|
||||
}
|
||||
|
||||
const openCatalog = (): void => {
|
||||
returnInstanceIdRef.current = activeInstanceId
|
||||
setTargetError(null)
|
||||
setCatalogOpen(true)
|
||||
}
|
||||
|
||||
const chooseApp = async (
|
||||
definition: WorkbarAppDefinition
|
||||
): Promise<void> => {
|
||||
if (definition.availability.state === 'unavailable') {
|
||||
return
|
||||
}
|
||||
|
||||
if (definition.instancePolicy === 'single') {
|
||||
const existing = instances.find(
|
||||
(instance) => instance.appId === definition.id
|
||||
)
|
||||
if (existing) {
|
||||
setCatalogOpen(false)
|
||||
setTargetError(null)
|
||||
pendingTabFocusRef.current = existing.id
|
||||
onActiveInstanceChange(existing.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (instances.length >= WORKBAR_LIMITS.maximumOpenInstances) {
|
||||
return
|
||||
}
|
||||
|
||||
const insertAfterInstanceId = activeInstanceId
|
||||
if (definition.id !== 'terminal') {
|
||||
focusCreatedInstanceRef.current = true
|
||||
setCatalogOpen(false)
|
||||
onCreateInstance({
|
||||
appId: definition.id,
|
||||
insertAfterInstanceId
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCreatingAppId('terminal')
|
||||
setTargetError(null)
|
||||
try {
|
||||
const targetRef = await onResolveTerminalTarget()
|
||||
focusCreatedInstanceRef.current = true
|
||||
setCatalogOpen(false)
|
||||
onCreateInstance({
|
||||
appId: 'terminal',
|
||||
insertAfterInstanceId,
|
||||
targetRef
|
||||
})
|
||||
} catch (error) {
|
||||
setTargetError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('sidebar.workbar.targetError')
|
||||
)
|
||||
} finally {
|
||||
setCreatingAppId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabKeyDown = (
|
||||
event: KeyboardEvent<HTMLButtonElement>,
|
||||
index: number
|
||||
): void => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (catalogOpen) {
|
||||
closeCatalog(false)
|
||||
}
|
||||
focusAndActivate(activeInstance?.id ?? instances[index]!.id)
|
||||
return
|
||||
}
|
||||
const nextIndex = adjacentIndex(
|
||||
event.key,
|
||||
index,
|
||||
instances.length
|
||||
)
|
||||
if (nextIndex === undefined) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
focusAndActivate(instances[nextIndex]!.id)
|
||||
}
|
||||
|
||||
const handleClose = async (
|
||||
instance: WorkbarTabInstance
|
||||
): Promise<void> => {
|
||||
const closingIndex = instances.findIndex(
|
||||
(candidate) => candidate.id === instance.id
|
||||
)
|
||||
const isActive = instance.id === activeInstanceId
|
||||
const nextActive = isActive
|
||||
? (instances[closingIndex + 1] ??
|
||||
instances[closingIndex - 1] ??
|
||||
null)
|
||||
: activeInstance
|
||||
|
||||
const closeResult = onCloseInstance(instance)
|
||||
const closeAccepted =
|
||||
closeResult &&
|
||||
typeof (closeResult as PromiseLike<boolean | void>).then ===
|
||||
'function'
|
||||
? await closeResult
|
||||
: closeResult
|
||||
if (closeAccepted === false) {
|
||||
return
|
||||
}
|
||||
pendingTabFocusRef.current = nextActive?.id ?? null
|
||||
if (nextActive && nextActive.id !== activeInstanceId) {
|
||||
onActiveInstanceChange(nextActive.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t('sidebar.workbar.ariaLabel')}
|
||||
className={joinClassNames('workbar-shell', className)}
|
||||
>
|
||||
<div className="workbar-shell__tab-row">
|
||||
<div className="workbar-shell__tab-scroll">
|
||||
<div
|
||||
aria-label={t('sidebar.workbar.tablist')}
|
||||
className="workbar-shell__tablist"
|
||||
role="tablist"
|
||||
>
|
||||
{instances.map((instance, index) => {
|
||||
const selected =
|
||||
!catalogOpen && instance.id === activeInstanceId
|
||||
const AppIcon = WORKBAR_APP_ICONS[instance.appId]
|
||||
return (
|
||||
<div
|
||||
className={joinClassNames(
|
||||
'workbar-shell__tab-item',
|
||||
selected && 'workbar-shell__tab-item--active'
|
||||
)}
|
||||
key={instance.id}
|
||||
>
|
||||
<button
|
||||
aria-controls={panelId(instance.id)}
|
||||
aria-selected={selected}
|
||||
className={joinClassNames(
|
||||
'workbar-shell__tab',
|
||||
selected && 'workbar-shell__tab--active'
|
||||
)}
|
||||
id={tabId(instance.id)}
|
||||
onClick={() => {
|
||||
setCatalogOpen(false)
|
||||
setTargetError(null)
|
||||
onActiveInstanceChange(instance.id)
|
||||
}}
|
||||
onKeyDown={(event) =>
|
||||
handleTabKeyDown(event, index)
|
||||
}
|
||||
role="tab"
|
||||
tabIndex={selected ? 0 : -1}
|
||||
title={
|
||||
appDefinitions.find(
|
||||
(application) =>
|
||||
application.id === instance.appId
|
||||
)?.description ?? instance.title
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="workbar-shell__tab-icon"
|
||||
>
|
||||
<AppIcon />
|
||||
</span>
|
||||
<span className="workbar-shell__tab-label">
|
||||
{instance.title}
|
||||
</span>
|
||||
{renderTabAdornment?.(instance)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('sidebar.workbar.close', {
|
||||
title: instance.title
|
||||
})}
|
||||
className="workbar-shell__tab-close"
|
||||
onClick={() => void handleClose(instance)}
|
||||
title={t('sidebar.workbar.close', {
|
||||
title: instance.title
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-expanded={catalogOpen}
|
||||
aria-label={t('sidebar.workbar.add')}
|
||||
className={joinClassNames(
|
||||
'workbar-shell__add',
|
||||
catalogOpen && 'workbar-shell__add--active'
|
||||
)}
|
||||
onClick={() =>
|
||||
catalogOpen ? closeCatalog(true) : openCatalog()
|
||||
}
|
||||
ref={addButtonRef}
|
||||
title={t('sidebar.workbar.add')}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="workbar-shell__content">
|
||||
{instances.map((instance) => (
|
||||
<div
|
||||
aria-labelledby={tabId(instance.id)}
|
||||
className="workbar-shell__tabpanel"
|
||||
hidden={catalogOpen || instance.id !== activeInstanceId}
|
||||
id={panelId(instance.id)}
|
||||
key={instance.id}
|
||||
role="tabpanel"
|
||||
tabIndex={0}
|
||||
>
|
||||
{renderPanel(instance)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{catalogOpen ? (
|
||||
<div
|
||||
className="workbar-shell__catalog"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeCatalog(true)
|
||||
}
|
||||
}}
|
||||
ref={catalogRef}
|
||||
>
|
||||
<div className="workbar-shell__catalog-header">
|
||||
<div>
|
||||
<h2>{t('sidebar.workbar.catalogTitle')}</h2>
|
||||
<p>{t('sidebar.workbar.catalogDescription')}</p>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => closeCatalog(true)}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.workbar.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{targetError ? (
|
||||
<p className="workbar-shell__catalog-error" role="alert">
|
||||
{targetError}
|
||||
</p>
|
||||
) : null}
|
||||
<ul
|
||||
aria-label={t('sidebar.workbar.catalog')}
|
||||
className="workbar-shell__catalog-list"
|
||||
>
|
||||
{appDefinitions.map((definition) => {
|
||||
const AppIcon = WORKBAR_APP_ICONS[definition.id]
|
||||
const existing =
|
||||
definition.instancePolicy === 'single' &&
|
||||
instances.some(
|
||||
(instance) => instance.appId === definition.id
|
||||
)
|
||||
const availability = definition.availability
|
||||
const unavailable =
|
||||
availability.state === 'unavailable'
|
||||
const atLimit =
|
||||
!existing &&
|
||||
instances.length >=
|
||||
WORKBAR_LIMITS.maximumOpenInstances
|
||||
const disabled =
|
||||
unavailable ||
|
||||
atLimit ||
|
||||
creatingAppId !== null
|
||||
return (
|
||||
<li
|
||||
className="workbar-shell__catalog-item"
|
||||
key={definition.id}
|
||||
>
|
||||
<button
|
||||
className="workbar-shell__catalog-choice"
|
||||
data-workbar-app-choice
|
||||
disabled={disabled}
|
||||
onClick={() => void chooseApp(definition)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`workbar-shell__app-icon workbar-shell__app-icon--${definition.icon}`}
|
||||
>
|
||||
<AppIcon />
|
||||
</span>
|
||||
<span className="workbar-shell__catalog-copy">
|
||||
<strong>{definition.label}</strong>
|
||||
<span>{definition.description}</span>
|
||||
<small>
|
||||
{existing
|
||||
? t('sidebar.workbar.existing')
|
||||
: definition.instancePolicy === 'multiple'
|
||||
? t('sidebar.workbar.multiple')
|
||||
: t('sidebar.workbar.single')}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
{availability.state === 'unavailable' ? (
|
||||
<div className="workbar-shell__unavailable">
|
||||
<span>{availability.reason}</span>
|
||||
{onResolveUnavailableApp ? (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() =>
|
||||
onResolveUnavailableApp(definition)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.workbar.resolve')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : atLimit ? (
|
||||
<small className="workbar-shell__limit">
|
||||
{t('sidebar.workbar.limit', {
|
||||
count: WORKBAR_LIMITS.maximumOpenInstances
|
||||
})}
|
||||
</small>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : instances.length === 0 ? (
|
||||
<div className="workbar-shell__empty">
|
||||
<strong>{t('sidebar.workbar.emptyTitle')}</strong>
|
||||
<p>{t('sidebar.workbar.emptyDescription')}</p>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={openCatalog}
|
||||
type="button"
|
||||
>
|
||||
{t('sidebar.workbar.open')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -62,7 +62,9 @@ export const app = {
|
||||
localWorkspace: 'Local workspace',
|
||||
loading: 'Loading',
|
||||
close: 'Close sidebar',
|
||||
toggle: 'Toggle sidebar'
|
||||
toggle: 'Toggle sidebar',
|
||||
resizeAriaLabel: 'Resize main sidebar',
|
||||
resizeValue: 'Main sidebar, {{width}} pixels'
|
||||
},
|
||||
topbar: {
|
||||
toggleAssistantSidebar: 'Toggle assistant workspace',
|
||||
|
||||
@@ -27,6 +27,8 @@ export const magicNotes = {
|
||||
analyzeAgain: 'Analyze again',
|
||||
analyzing: 'Analyzing…',
|
||||
openSourceNote: 'Open source note',
|
||||
hideListPane: 'Hide left list',
|
||||
showListPane: 'Show left list',
|
||||
hideAiComments: 'Hide AI comments',
|
||||
showAiComments: 'Show AI comments',
|
||||
pinNote: 'Pin note',
|
||||
@@ -161,6 +163,8 @@ export const magicNotes = {
|
||||
'Choose “Analyze with AI” on an entry to show comments here.'
|
||||
},
|
||||
accessibility: {
|
||||
resizeListPane: 'Resize the note list and editor panes',
|
||||
listPaneWidth: 'Left list, {{width}} pixels',
|
||||
resizeAiPane: 'Resize the editor and AI comments panes',
|
||||
aiPaneWidth: 'AI comments pane, {{width}} pixels'
|
||||
},
|
||||
|
||||
@@ -191,6 +191,97 @@ export const workspace = {
|
||||
results: {
|
||||
label: 'Results',
|
||||
description: 'View generated or imported standalone results'
|
||||
},
|
||||
terminal: {
|
||||
label: 'Terminal',
|
||||
description: 'Open a user terminal for the current project'
|
||||
}
|
||||
},
|
||||
workbar: {
|
||||
ariaLabel: 'Workbar applications',
|
||||
tablist: 'Open workbar applications',
|
||||
add: 'Open workbar application',
|
||||
close: 'Close {{title}}',
|
||||
catalogTitle: 'New workbar application',
|
||||
catalogDescription: 'Choose an application to open in the workbar.',
|
||||
cancel: 'Cancel',
|
||||
targetError:
|
||||
'Could not resolve the current project terminal target. Try again.',
|
||||
catalog: 'Workbar application catalog',
|
||||
existing: 'Already open. Selecting switches to the existing instance.',
|
||||
multiple: 'Multiple instances supported',
|
||||
single: 'Single-instance application',
|
||||
resolve: 'Resolve',
|
||||
limit: 'You can open up to {{count}} instances.',
|
||||
emptyTitle: 'No workbar applications are open',
|
||||
emptyDescription: 'Open an application to switch to it here.',
|
||||
open: 'Open application'
|
||||
},
|
||||
terminal: {
|
||||
loading: 'Loading terminal…',
|
||||
ariaLabel: 'User terminal: {{title}}',
|
||||
location: {
|
||||
local: 'Local',
|
||||
target: 'Target'
|
||||
},
|
||||
states: {
|
||||
starting: 'Connecting',
|
||||
running: 'Connected',
|
||||
exited: 'Shell exited',
|
||||
interrupted: 'Connection interrupted',
|
||||
closing: 'Closing',
|
||||
failed: 'Connection failed',
|
||||
creating: 'Creating'
|
||||
},
|
||||
toolbar: {
|
||||
ariaLabel: 'Terminal actions',
|
||||
search: 'Search terminal',
|
||||
copy: 'Copy selection',
|
||||
paste: 'Paste',
|
||||
selectAll: 'Select all',
|
||||
selectAllAriaLabel: 'Select all terminal content',
|
||||
clear: 'Clear terminal',
|
||||
rename: 'Rename terminal'
|
||||
},
|
||||
reopen: {
|
||||
interrupted: 'Reconnect with a new shell',
|
||||
default: 'Reopen'
|
||||
},
|
||||
search: {
|
||||
label: 'Search terminal content',
|
||||
next: 'Find next'
|
||||
},
|
||||
rename: {
|
||||
label: 'Terminal name',
|
||||
save: 'Save name',
|
||||
cancel: 'Cancel'
|
||||
},
|
||||
status: {
|
||||
emulatorAriaLabel: 'Terminal input and output',
|
||||
ariaLabel: 'Terminal status',
|
||||
readingDirectory: 'Reading directory',
|
||||
readingShell: 'Reading shell',
|
||||
exitCode: '(exit code {{code}})'
|
||||
},
|
||||
errors: {
|
||||
ack: 'Could not acknowledge terminal output. Try again.',
|
||||
write: 'Could not write input to the terminal.',
|
||||
snapshot: 'Could not read the terminal session. Reopen it.',
|
||||
create: 'Could not create the terminal. Try again.',
|
||||
resize: 'Could not synchronize the terminal size.',
|
||||
copy: 'Could not copy the terminal selection.',
|
||||
paste: 'Could not paste into the terminal.',
|
||||
closePrevious: 'Could not close the previous terminal. Try again.'
|
||||
},
|
||||
closeDialog: {
|
||||
ariaLabel: 'Close terminal',
|
||||
title: 'Close terminal?',
|
||||
description:
|
||||
'Closing this tab ends the shell and its child processes.',
|
||||
cancel: 'Cancel',
|
||||
closing: 'Closing…',
|
||||
confirm: 'Close terminal',
|
||||
error: 'Could not close the terminal. Try again.'
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
|
||||
@@ -59,7 +59,9 @@ export const app = {
|
||||
localWorkspace: '本地工作区',
|
||||
loading: '加载中',
|
||||
close: '关闭侧栏',
|
||||
toggle: '切换侧栏'
|
||||
toggle: '切换侧栏',
|
||||
resizeAriaLabel: '调整主侧栏宽度',
|
||||
resizeValue: '主侧栏 {{width}} 像素'
|
||||
},
|
||||
topbar: {
|
||||
toggleAssistantSidebar: '切换助手工作栏',
|
||||
|
||||
@@ -23,6 +23,8 @@ export const magicNotes = {
|
||||
analyzeAgain: '重新分析',
|
||||
analyzing: '分析中…',
|
||||
openSourceNote: '打开原笔记修改',
|
||||
hideListPane: '隐藏左侧列表',
|
||||
showListPane: '显示左侧列表',
|
||||
hideAiComments: '隐藏 AI 评论',
|
||||
showAiComments: '显示 AI 评论',
|
||||
pinNote: '置顶笔记',
|
||||
@@ -149,6 +151,8 @@ export const magicNotes = {
|
||||
manualHint: '在记录上点击“AI 分析”,评论会显示在这里。'
|
||||
},
|
||||
accessibility: {
|
||||
resizeListPane: '调整笔记列表与编辑区宽度',
|
||||
listPaneWidth: '左侧列表 {{width}} 像素',
|
||||
resizeAiPane: '调整编辑区与 AI 评论宽度',
|
||||
aiPaneWidth: 'AI 评论栏 {{width}} 像素'
|
||||
},
|
||||
|
||||
@@ -185,6 +185,95 @@ export const workspace = {
|
||||
results: {
|
||||
label: '成果',
|
||||
description: '查看生成或手动导入的独立成果'
|
||||
},
|
||||
terminal: {
|
||||
label: '终端',
|
||||
description: '打开当前项目的用户终端'
|
||||
}
|
||||
},
|
||||
workbar: {
|
||||
ariaLabel: '工作栏应用',
|
||||
tablist: '已打开的工作栏应用',
|
||||
add: '打开工作栏应用',
|
||||
close: '关闭{{title}}',
|
||||
catalogTitle: '新建工作栏应用',
|
||||
catalogDescription: '选择要在工作栏中打开的应用。',
|
||||
cancel: '取消',
|
||||
targetError: '无法确定当前项目的终端目标,请重试。',
|
||||
catalog: '工作栏应用目录',
|
||||
existing: '已打开,选择后切换到现有实例',
|
||||
multiple: '可打开多个实例',
|
||||
single: '单实例应用',
|
||||
resolve: '前往处理',
|
||||
limit: '最多可打开 {{count}} 个实例。',
|
||||
emptyTitle: '还没有打开的工作栏应用',
|
||||
emptyDescription: '打开应用后,可以在这里快速切换和处理内容。',
|
||||
open: '打开应用'
|
||||
},
|
||||
terminal: {
|
||||
loading: '正在加载终端…',
|
||||
ariaLabel: '用户终端:{{title}}',
|
||||
location: {
|
||||
local: '本机',
|
||||
target: '目标'
|
||||
},
|
||||
states: {
|
||||
starting: '正在连接',
|
||||
running: '已连接',
|
||||
exited: 'Shell 已退出',
|
||||
interrupted: '连接已中断',
|
||||
closing: '正在关闭',
|
||||
failed: '连接失败',
|
||||
creating: '正在创建'
|
||||
},
|
||||
toolbar: {
|
||||
ariaLabel: '终端操作',
|
||||
search: '搜索终端',
|
||||
copy: '复制选中内容',
|
||||
paste: '粘贴',
|
||||
selectAll: '全选',
|
||||
selectAllAriaLabel: '全选终端内容',
|
||||
clear: '清屏',
|
||||
rename: '重命名终端'
|
||||
},
|
||||
reopen: {
|
||||
interrupted: '重新连接并新建 Shell',
|
||||
default: '重新打开'
|
||||
},
|
||||
search: {
|
||||
label: '搜索终端内容',
|
||||
next: '查找下一个'
|
||||
},
|
||||
rename: {
|
||||
label: '终端名称',
|
||||
save: '保存名称',
|
||||
cancel: '取消'
|
||||
},
|
||||
status: {
|
||||
emulatorAriaLabel: '终端输入与输出',
|
||||
ariaLabel: '终端状态',
|
||||
readingDirectory: '正在读取目录',
|
||||
readingShell: '正在读取 Shell',
|
||||
exitCode: '(退出码 {{code}})'
|
||||
},
|
||||
errors: {
|
||||
ack: '无法确认终端输出,请重试。',
|
||||
write: '无法向终端写入输入。',
|
||||
snapshot: '无法读取终端会话,请重新打开。',
|
||||
create: '无法创建终端,请重试。',
|
||||
resize: '无法同步终端窗口尺寸。',
|
||||
copy: '无法复制终端选区。',
|
||||
paste: '无法粘贴到终端。',
|
||||
closePrevious: '无法关闭原终端,请重试。'
|
||||
},
|
||||
closeDialog: {
|
||||
ariaLabel: '关闭终端',
|
||||
title: '关闭终端?',
|
||||
description: '关闭此 Tab 将结束 Shell 及其子进程。',
|
||||
cancel: '取消',
|
||||
closing: '正在关闭…',
|
||||
confirm: '关闭终端',
|
||||
error: '无法关闭终端,请重试。'
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
|
||||
+482
-17
@@ -76,7 +76,9 @@
|
||||
"Microsoft YaHei UI", "Noto Sans SC Variable", "Noto Sans CJK SC",
|
||||
sans-serif;
|
||||
--font-family-mono:
|
||||
"Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
"Cascadia Mono", "Cascadia Code", "SFMono-Regular", Menlo, Monaco,
|
||||
Consolas, "Noto Sans Mono", "DejaVu Sans Mono", "Liberation Mono",
|
||||
monospace;
|
||||
font-family: var(--font-family-ui);
|
||||
font-size: var(--font-body);
|
||||
font-optical-sizing: auto;
|
||||
@@ -1250,6 +1252,7 @@
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
container: magic-notes-page / inline-size;
|
||||
}
|
||||
|
||||
.magic-notes-layout {
|
||||
@@ -1261,14 +1264,26 @@
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
container: speech-model-list / inline-size;
|
||||
grid-template-columns:
|
||||
220px minmax(300px, 1fr) 9px
|
||||
var(--magic-notes-list-width, 220px) 9px
|
||||
minmax(300px, 1fr) 9px
|
||||
var(--magic-notes-ai-width, 280px);
|
||||
}
|
||||
|
||||
.magic-notes-layout--ai-hidden {
|
||||
grid-template-columns: 220px minmax(300px, 1fr);
|
||||
grid-template-columns:
|
||||
var(--magic-notes-list-width, 220px) 9px
|
||||
minmax(300px, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-layout--list-hidden {
|
||||
grid-template-columns:
|
||||
minmax(300px, 1fr) 9px
|
||||
var(--magic-notes-ai-width, 280px);
|
||||
}
|
||||
|
||||
.magic-notes-layout--list-hidden.magic-notes-layout--ai-hidden {
|
||||
grid-template-columns: minmax(300px, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-layout--resizing {
|
||||
@@ -1276,21 +1291,34 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle,
|
||||
.magic-notes-ai-resize-handle {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 9px;
|
||||
min-width: 9px;
|
||||
padding: 0;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--surface-subtle) 0 4px,
|
||||
var(--surface-raised) 4px 100%
|
||||
);
|
||||
}
|
||||
|
||||
.magic-notes-ai-resize-handle {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--surface-raised) 0 4px,
|
||||
var(--surface-subtle) 4px 100%
|
||||
);
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle::after,
|
||||
.magic-notes-ai-resize-handle::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -1304,14 +1332,19 @@
|
||||
background var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle:hover::after,
|
||||
.magic-notes-list-resize-handle:focus-visible::after,
|
||||
.magic-notes-ai-resize-handle:hover::after,
|
||||
.magic-notes-ai-resize-handle:focus-visible::after,
|
||||
.magic-notes-layout--resizing
|
||||
.magic-notes-layout--list-resizing
|
||||
> .magic-notes-list-resize-handle::after,
|
||||
.magic-notes-layout--ai-resizing
|
||||
> .magic-notes-ai-resize-handle::after {
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle:focus-visible,
|
||||
.magic-notes-ai-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
@@ -1330,10 +1363,14 @@
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.magic-notes-list-pane[hidden],
|
||||
.magic-notes-ai-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.magic-notes-list-pane {
|
||||
display: flex;
|
||||
padding: var(--space-4);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
@@ -2175,12 +2212,18 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
@container magic-notes-page (max-width: 800px) {
|
||||
.magic-notes-layout {
|
||||
grid-template-columns: 210px minmax(320px, 1fr);
|
||||
grid-template-columns:
|
||||
var(--magic-notes-list-width, 210px) 9px
|
||||
minmax(320px, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.magic-notes-layout--list-hidden {
|
||||
grid-template-columns: minmax(320px, 1fr);
|
||||
}
|
||||
|
||||
.magic-notes-ai-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
@@ -2193,11 +2236,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 720px) {
|
||||
.magic-notes-page {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@container magic-notes-page (max-width: 720px) {
|
||||
.magic-notes-layout {
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
@@ -2205,6 +2244,10 @@
|
||||
grid-template-rows: auto auto auto;
|
||||
}
|
||||
|
||||
.magic-notes-list-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.magic-notes-list-pane {
|
||||
max-height: 240px;
|
||||
border-right: 0;
|
||||
@@ -2321,7 +2364,7 @@ textarea:focus-visible {
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex: 0 0 278px;
|
||||
flex: 0 0 var(--primary-sidebar-width, 278px);
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
padding: 20px 14px 14px;
|
||||
@@ -2334,12 +2377,63 @@ textarea:focus-visible {
|
||||
padding 180ms ease;
|
||||
}
|
||||
|
||||
.sidebar--resizing {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.sidebar--closed {
|
||||
flex-basis: 0;
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.sidebar > .brand,
|
||||
.sidebar > .project-switcher,
|
||||
.sidebar > .new-chat,
|
||||
.sidebar > .sidebar-search,
|
||||
.sidebar > .primary-nav,
|
||||
.sidebar > .conversation-list,
|
||||
.sidebar > .sidebar-footer {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.primary-sidebar-resize-handle {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
width: 9px;
|
||||
min-width: 9px;
|
||||
padding: 0;
|
||||
margin-left: -5px;
|
||||
background: transparent;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.primary-sidebar-resize-handle::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 4px;
|
||||
width: 1px;
|
||||
background: var(--border-subtle);
|
||||
content: '';
|
||||
transition:
|
||||
width var(--motion-fast) ease-out,
|
||||
background var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.primary-sidebar-resize-handle:hover::after,
|
||||
.primary-sidebar-resize-handle:focus-visible::after,
|
||||
.sidebar--resizing + .primary-sidebar-resize-handle::after {
|
||||
width: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.primary-sidebar-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
@@ -3714,6 +3808,372 @@ button > svg {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.workbar-shell {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 46px;
|
||||
padding: 6px 46px 0 8px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--surface-subtle);
|
||||
flex: 0 0 46px;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-scroll {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbar-shell__tablist {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 88px;
|
||||
max-width: 180px;
|
||||
height: 39px;
|
||||
align-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius-control) var(--radius-control) 0 0;
|
||||
background: transparent;
|
||||
transition:
|
||||
border-color var(--motion-fast) ease-out,
|
||||
background var(--motion-fast) ease-out,
|
||||
box-shadow var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-item:hover {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-item--active {
|
||||
border-color: var(--border-default);
|
||||
background: var(--surface-raised);
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
|
||||
.workbar-shell__tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 32px 0 var(--space-2);
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: var(--font-caption);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workbar-shell__tab:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.workbar-shell__tab--active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-icon {
|
||||
display: grid;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: 0 0 15px;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-icon > svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.workbar-shell__tab--active .workbar-shell__tab-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-label {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-close,
|
||||
.workbar-shell__add {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-close {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
right: var(--space-1);
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
background: var(--surface-raised);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%) scale(0.86);
|
||||
transition:
|
||||
opacity var(--motion-fast) ease-out,
|
||||
transform var(--motion-fast) ease-out,
|
||||
background var(--motion-fast) ease-out,
|
||||
color var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-close > svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.workbar-shell__tab-item:hover .workbar-shell__tab-close,
|
||||
.workbar-shell__tab-item:focus-within .workbar-shell__tab-close {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
|
||||
.workbar-shell__tab-close:hover {
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.workbar-shell__tab-close {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.workbar-shell__add:hover,
|
||||
.workbar-shell__add--active {
|
||||
background: var(--accent-selected);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workbar-shell__add {
|
||||
margin-bottom: 5px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workbar-shell__content,
|
||||
.workbar-shell__tabpanel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workbar-shell__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workbar-shell__tabpanel {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workbar-shell__tabpanel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbar-shell__catalog,
|
||||
.workbar-shell__empty {
|
||||
min-height: 0;
|
||||
padding: var(--space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-header h2,
|
||||
.workbar-shell__empty strong {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-header p,
|
||||
.workbar-shell__empty p {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-list {
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: var(--space-4) 0 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-item {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-choice {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: var(--space-3);
|
||||
align-items: flex-start;
|
||||
border-radius: var(--radius-card);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workbar-shell__app-icon {
|
||||
display: grid;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: var(--space-3);
|
||||
flex: 0 0 32px;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.workbar-shell__app-icon > svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-choice:hover:not(:disabled) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-choice:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-copy span,
|
||||
.workbar-shell__catalog-copy small,
|
||||
.workbar-shell__unavailable,
|
||||
.workbar-shell__limit {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.workbar-shell__unavailable {
|
||||
display: flex;
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.workbar-shell__catalog-error {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--danger-border);
|
||||
border-radius: var(--radius-control);
|
||||
margin: var(--space-3) 0 0;
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.workbar-shell__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.workbar-shell__empty .primary-button {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.assistant-sidebar__overlay {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
inset: 46px 0 0;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.assistant-sidebar__terminal-close {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
padding: var(--space-6);
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.assistant-sidebar__terminal-close h2,
|
||||
.assistant-sidebar__terminal-close p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.assistant-sidebar__terminal-close > div {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.assistant-sidebar__tabs {
|
||||
display: grid;
|
||||
min-height: 46px;
|
||||
@@ -3767,6 +4227,7 @@ button > svg {
|
||||
}
|
||||
|
||||
.assistant-sidebar__body {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -14829,7 +15290,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.sidebar {
|
||||
flex-basis: 236px;
|
||||
flex-basis: var(--primary-sidebar-width, 236px);
|
||||
}
|
||||
|
||||
.brand,
|
||||
@@ -14877,6 +15338,10 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.primary-sidebar-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
.terminal-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 300px;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.terminal-panel--loading {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar,
|
||||
.terminal-panel__inline-form,
|
||||
.terminal-panel__status,
|
||||
.terminal-panel__error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--surface-subtle);
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button,
|
||||
.terminal-panel__inline-form button,
|
||||
.terminal-panel__error button {
|
||||
display: inline-flex;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button:hover:not(:disabled),
|
||||
.terminal-panel__inline-form button:hover:not(:disabled),
|
||||
.terminal-panel__error button:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button:focus-visible,
|
||||
.terminal-panel__inline-form button:focus-visible,
|
||||
.terminal-panel__inline-form input:focus-visible,
|
||||
.terminal-panel__error button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button:disabled,
|
||||
.terminal-panel__inline-form button:disabled,
|
||||
.terminal-panel__error button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar .terminal-panel__reopen {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.terminal-panel__inline-form {
|
||||
flex-wrap: wrap;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-body);
|
||||
}
|
||||
|
||||
.terminal-panel__inline-form input {
|
||||
min-width: 120px;
|
||||
min-height: 32px;
|
||||
flex: 1 1 160px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-control);
|
||||
}
|
||||
|
||||
.terminal-panel__error {
|
||||
justify-content: space-between;
|
||||
color: var(--danger);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
.terminal-panel__emulator {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: var(--space-2);
|
||||
overflow: hidden;
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
.terminal-panel__emulator .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-panel__status {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--border-default);
|
||||
border-bottom: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.terminal-panel__status span {
|
||||
min-width: 0;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-panel__status span + span::before {
|
||||
content: "·";
|
||||
margin-right: var(--space-2);
|
||||
color: var(--border-control);
|
||||
}
|
||||
|
||||
.terminal-panel__status strong {
|
||||
margin-left: auto;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-panel__status strong[data-state="running"] {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.terminal-panel__status strong[data-state="interrupted"],
|
||||
.terminal-panel__status strong[data-state="failed"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.terminal-panel__status strong[data-state="starting"],
|
||||
.terminal-panel__status strong[data-state="closing"] {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.terminal-panel__announcement {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.terminal-panel__toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.terminal-panel__toolbar-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.terminal-panel__status span {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.terminal-panel__status strong {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,16 @@ import type {
|
||||
FeedbackSubmitInput,
|
||||
FeedbackSubmitResult
|
||||
} from './feedback-contracts'
|
||||
import type {
|
||||
TerminalAckRequest,
|
||||
TerminalCloseRequest,
|
||||
TerminalCreateRequest,
|
||||
TerminalEvent,
|
||||
TerminalResizeRequest,
|
||||
TerminalSnapshot,
|
||||
TerminalSnapshotRequest,
|
||||
TerminalWriteRequest
|
||||
} from './terminal-contracts'
|
||||
export type {
|
||||
KnowledgeTaskError,
|
||||
KnowledgeTaskItem,
|
||||
@@ -1548,6 +1558,17 @@ export type DesktopApi = {
|
||||
stop: (conversationId: string) => Promise<void>
|
||||
onState: (listener: (state: BrowserLiveState) => void) => () => void
|
||||
}
|
||||
terminal: {
|
||||
create: (request: TerminalCreateRequest) => Promise<TerminalSnapshot>
|
||||
write: (request: TerminalWriteRequest) => Promise<void>
|
||||
resize: (request: TerminalResizeRequest) => Promise<void>
|
||||
close: (request: TerminalCloseRequest) => Promise<TerminalSnapshot>
|
||||
getSnapshot: (
|
||||
request: TerminalSnapshotRequest
|
||||
) => Promise<TerminalSnapshot>
|
||||
ack: (request: TerminalAckRequest) => Promise<void>
|
||||
onEvent: (listener: (event: TerminalEvent) => void) => () => void
|
||||
}
|
||||
settings: {
|
||||
getRuntime: () => Promise<RuntimeSettings>
|
||||
updateRuntime: (input: RuntimeSettingsInput) => Promise<RuntimeSettings>
|
||||
|
||||
@@ -23,6 +23,13 @@ export const ipcChannels = {
|
||||
browserInteract: 'browser:interact',
|
||||
browserStop: 'browser:stop',
|
||||
browserState: 'browser:state',
|
||||
terminalCreate: 'terminal:create',
|
||||
terminalWrite: 'terminal:write',
|
||||
terminalResize: 'terminal:resize',
|
||||
terminalClose: 'terminal:close',
|
||||
terminalSnapshot: 'terminal:snapshot',
|
||||
terminalAck: 'terminal:ack',
|
||||
terminalEvent: 'terminal:event',
|
||||
runtimeSettingsGet: 'settings:runtime:get',
|
||||
runtimeSettingsUpdate: 'settings:runtime:update',
|
||||
runtimeSettingsSelectWorkspace: 'settings:runtime:select-workspace',
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_LIMITS,
|
||||
terminalAckRequestSchema,
|
||||
terminalCreateRequestSchema,
|
||||
terminalEventSchema,
|
||||
terminalResizeRequestSchema,
|
||||
terminalSnapshotSchema,
|
||||
terminalWriteRequestSchema
|
||||
} from './terminal-contracts'
|
||||
|
||||
const sessionId = '00000000-0000-4000-8000-000000000101'
|
||||
const projectId = '00000000-0000-4000-8000-000000000201'
|
||||
|
||||
describe('terminal contracts', () => {
|
||||
it('accepts bounded create, write, resize, snapshot, event, and ACK data', () => {
|
||||
expect(
|
||||
terminalCreateRequestSchema.parse({
|
||||
target: { type: 'project', projectId },
|
||||
cols: 120,
|
||||
rows: 30
|
||||
})
|
||||
).toEqual({
|
||||
target: { type: 'project', projectId },
|
||||
cols: 120,
|
||||
rows: 30
|
||||
})
|
||||
expect(
|
||||
terminalWriteRequestSchema.parse({ sessionId, data: '你好\r' })
|
||||
).toEqual({ sessionId, data: '你好\r' })
|
||||
expect(
|
||||
terminalResizeRequestSchema.parse({
|
||||
sessionId,
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
terminalSnapshotSchema.parse({
|
||||
sessionId,
|
||||
target: { type: 'project', projectId },
|
||||
targetLabel: '构建主机',
|
||||
title: '终端 · 构建主机 1',
|
||||
state: 'running',
|
||||
shell: 'bash',
|
||||
workingDirectory: '/home/builder',
|
||||
size: { cols: 120, rows: 30 },
|
||||
lastSequence: 3,
|
||||
exit: null,
|
||||
error: null
|
||||
})
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
terminalEventSchema.parse({
|
||||
type: 'output',
|
||||
sessionId,
|
||||
sequence: 4,
|
||||
data: '\u001b[32mdone\u001b[0m\r\n'
|
||||
})
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
terminalAckRequestSchema.parse({ sessionId, sequence: 4 })
|
||||
).toEqual({ sessionId, sequence: 4 })
|
||||
})
|
||||
|
||||
it('uses UTF-8 byte limits for input and output chunks', () => {
|
||||
expect(
|
||||
terminalWriteRequestSchema.safeParse({
|
||||
sessionId,
|
||||
data: '界'.repeat(
|
||||
Math.floor(TERMINAL_LIMITS.maximumInputBytes / 3)
|
||||
)
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
terminalWriteRequestSchema.safeParse({
|
||||
sessionId,
|
||||
data: '界'.repeat(
|
||||
Math.floor(TERMINAL_LIMITS.maximumInputBytes / 3) + 1
|
||||
)
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
terminalEventSchema.safeParse({
|
||||
type: 'output',
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
data: 'x'.repeat(TERMINAL_LIMITS.maximumEventBytes + 1)
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('enforces session, sequence, and terminal dimension boundaries', () => {
|
||||
for (const size of [
|
||||
{ cols: TERMINAL_LIMITS.minimumColumns - 1, rows: 24 },
|
||||
{ cols: TERMINAL_LIMITS.maximumColumns + 1, rows: 24 },
|
||||
{ cols: 80, rows: TERMINAL_LIMITS.minimumRows - 1 },
|
||||
{ cols: 80, rows: TERMINAL_LIMITS.maximumRows + 1 },
|
||||
{ cols: 80.5, rows: 24 }
|
||||
]) {
|
||||
expect(
|
||||
terminalResizeRequestSchema.safeParse({
|
||||
sessionId,
|
||||
...size
|
||||
}).success
|
||||
).toBe(false)
|
||||
}
|
||||
expect(
|
||||
terminalWriteRequestSchema.safeParse({
|
||||
sessionId: 'renderer-selected-pid-123',
|
||||
data: 'pwd\r'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
terminalEventSchema.safeParse({
|
||||
type: 'state',
|
||||
sessionId,
|
||||
sequence: 0,
|
||||
state: 'running'
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
terminalAckRequestSchema.safeParse({
|
||||
sessionId,
|
||||
sequence: -1
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('strictly rejects shell, path, command, environment, and credentials', () => {
|
||||
for (const extra of [
|
||||
{ shell: 'C:\\custom\\shell.exe' },
|
||||
{ cwd: 'C:\\private' },
|
||||
{ command: 'curl example.invalid' },
|
||||
{ environment: { MODEL_API_KEY: 'secret' } },
|
||||
{ password: 'secret' },
|
||||
{ privateKey: 'secret' },
|
||||
{ pid: 42 }
|
||||
]) {
|
||||
expect(
|
||||
terminalCreateRequestSchema.safeParse({
|
||||
target: { type: 'local' },
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
...extra
|
||||
}).success
|
||||
).toBe(false)
|
||||
}
|
||||
expect(
|
||||
terminalCreateRequestSchema.safeParse({
|
||||
target: {
|
||||
type: 'project',
|
||||
projectId,
|
||||
hostname: 'host.internal',
|
||||
username: 'builder'
|
||||
},
|
||||
cols: 80,
|
||||
rows: 24
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('requires coherent exited snapshots and strict event variants', () => {
|
||||
const snapshot = {
|
||||
sessionId,
|
||||
target: { type: 'local' },
|
||||
targetLabel: '本机',
|
||||
title: '终端 · 本机 1',
|
||||
state: 'exited',
|
||||
shell: 'PowerShell 7',
|
||||
workingDirectory: 'C:\\Users\\tester',
|
||||
size: { cols: 80, rows: 24 },
|
||||
lastSequence: 2,
|
||||
exit: null,
|
||||
error: null
|
||||
} as const
|
||||
expect(terminalSnapshotSchema.safeParse(snapshot).success).toBe(false)
|
||||
expect(
|
||||
terminalSnapshotSchema.safeParse({
|
||||
...snapshot,
|
||||
exit: { exitCode: 0, signal: null }
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
terminalEventSchema.safeParse({
|
||||
type: 'output',
|
||||
sessionId,
|
||||
sequence: 1,
|
||||
data: 'ok',
|
||||
state: 'running'
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
import { z } from 'zod'
|
||||
import { workbarTargetRefSchema } from './workbar-contracts'
|
||||
|
||||
export const TERMINAL_LIMITS = {
|
||||
maximumSessionsPerWindow: 12,
|
||||
minimumColumns: 2,
|
||||
maximumColumns: 500,
|
||||
minimumRows: 1,
|
||||
maximumRows: 200,
|
||||
maximumTitleBytes: 256,
|
||||
maximumShellLabelBytes: 256,
|
||||
maximumTargetLabelBytes: 512,
|
||||
maximumWorkingDirectoryBytes: 4 * 1024,
|
||||
maximumInputBytes: 64 * 1024,
|
||||
maximumEventBytes: 64 * 1024,
|
||||
maximumPendingEvents: 256,
|
||||
maximumBufferedOutputBytes: 4 * 1024 * 1024,
|
||||
maximumErrorMessageBytes: 2 * 1024
|
||||
} as const
|
||||
|
||||
const boundedUtf8StringSchema = (
|
||||
maximumBytes: number,
|
||||
minimumBytes = 0
|
||||
): z.ZodType<string> =>
|
||||
z.string().refine(
|
||||
(value) => {
|
||||
const size = new TextEncoder().encode(value).byteLength
|
||||
return size >= minimumBytes && size <= maximumBytes
|
||||
},
|
||||
`Value must contain ${minimumBytes}-${maximumBytes} UTF-8 bytes`
|
||||
)
|
||||
|
||||
export const terminalSessionIdSchema = z.string().uuid()
|
||||
export type TerminalSessionId = z.infer<
|
||||
typeof terminalSessionIdSchema
|
||||
>
|
||||
|
||||
export const terminalTargetSchema = workbarTargetRefSchema
|
||||
export type TerminalTarget = z.infer<typeof terminalTargetSchema>
|
||||
|
||||
export const terminalSessionStateSchema = z.enum([
|
||||
'starting',
|
||||
'running',
|
||||
'exited',
|
||||
'interrupted',
|
||||
'closing',
|
||||
'failed'
|
||||
])
|
||||
export type TerminalSessionState = z.infer<
|
||||
typeof terminalSessionStateSchema
|
||||
>
|
||||
|
||||
export const terminalErrorCodeSchema = z.enum([
|
||||
'session-limit-reached',
|
||||
'target-not-found',
|
||||
'target-unavailable',
|
||||
'host-unverified',
|
||||
'credentials-unavailable',
|
||||
'interactive-shell-unsupported',
|
||||
'launch-failed',
|
||||
'connection-failed',
|
||||
'session-not-found',
|
||||
'session-not-running',
|
||||
'interrupted',
|
||||
'output-limit-exceeded',
|
||||
'internal-error'
|
||||
])
|
||||
export type TerminalErrorCode = z.infer<
|
||||
typeof terminalErrorCodeSchema
|
||||
>
|
||||
|
||||
export const terminalErrorSchema = z
|
||||
.object({
|
||||
code: terminalErrorCodeSchema,
|
||||
message: boundedUtf8StringSchema(
|
||||
TERMINAL_LIMITS.maximumErrorMessageBytes,
|
||||
1
|
||||
),
|
||||
retryable: z.boolean()
|
||||
})
|
||||
.strict()
|
||||
export type TerminalError = z.infer<typeof terminalErrorSchema>
|
||||
|
||||
export const terminalSizeSchema = z
|
||||
.object({
|
||||
cols: z
|
||||
.number()
|
||||
.int()
|
||||
.min(TERMINAL_LIMITS.minimumColumns)
|
||||
.max(TERMINAL_LIMITS.maximumColumns),
|
||||
rows: z
|
||||
.number()
|
||||
.int()
|
||||
.min(TERMINAL_LIMITS.minimumRows)
|
||||
.max(TERMINAL_LIMITS.maximumRows)
|
||||
})
|
||||
.strict()
|
||||
export type TerminalSize = z.infer<typeof terminalSizeSchema>
|
||||
|
||||
export const terminalCreateRequestSchema = z
|
||||
.object({
|
||||
target: terminalTargetSchema,
|
||||
cols: terminalSizeSchema.shape.cols,
|
||||
rows: terminalSizeSchema.shape.rows
|
||||
})
|
||||
.strict()
|
||||
export type TerminalCreateRequest = z.infer<
|
||||
typeof terminalCreateRequestSchema
|
||||
>
|
||||
|
||||
export const terminalWriteRequestSchema = z
|
||||
.object({
|
||||
sessionId: terminalSessionIdSchema,
|
||||
data: boundedUtf8StringSchema(TERMINAL_LIMITS.maximumInputBytes, 1)
|
||||
})
|
||||
.strict()
|
||||
export type TerminalWriteRequest = z.infer<
|
||||
typeof terminalWriteRequestSchema
|
||||
>
|
||||
|
||||
export const terminalResizeRequestSchema = z
|
||||
.object({
|
||||
sessionId: terminalSessionIdSchema,
|
||||
cols: terminalSizeSchema.shape.cols,
|
||||
rows: terminalSizeSchema.shape.rows
|
||||
})
|
||||
.strict()
|
||||
export type TerminalResizeRequest = z.infer<
|
||||
typeof terminalResizeRequestSchema
|
||||
>
|
||||
|
||||
export const terminalCloseRequestSchema = z
|
||||
.object({
|
||||
sessionId: terminalSessionIdSchema
|
||||
})
|
||||
.strict()
|
||||
export type TerminalCloseRequest = z.infer<
|
||||
typeof terminalCloseRequestSchema
|
||||
>
|
||||
|
||||
export const terminalSnapshotRequestSchema = terminalCloseRequestSchema
|
||||
export type TerminalSnapshotRequest = TerminalCloseRequest
|
||||
|
||||
const terminalSequenceSchema = z.number().int().safe().min(0)
|
||||
const terminalEventSequenceSchema = terminalSequenceSchema.min(1)
|
||||
|
||||
export const terminalAckRequestSchema = z
|
||||
.object({
|
||||
sessionId: terminalSessionIdSchema,
|
||||
sequence: terminalSequenceSchema
|
||||
})
|
||||
.strict()
|
||||
export type TerminalAckRequest = z.infer<
|
||||
typeof terminalAckRequestSchema
|
||||
>
|
||||
|
||||
export const terminalExitSchema = z
|
||||
.object({
|
||||
exitCode: z.number().int().safe().nullable(),
|
||||
signal: boundedUtf8StringSchema(128, 1).nullable()
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(exit) => exit.exitCode !== null || exit.signal !== null,
|
||||
'An exit must include an exit code or signal'
|
||||
)
|
||||
export type TerminalExit = z.infer<typeof terminalExitSchema>
|
||||
|
||||
export const terminalSnapshotSchema = z
|
||||
.object({
|
||||
sessionId: terminalSessionIdSchema,
|
||||
target: terminalTargetSchema,
|
||||
targetLabel: boundedUtf8StringSchema(
|
||||
TERMINAL_LIMITS.maximumTargetLabelBytes,
|
||||
1
|
||||
),
|
||||
title: boundedUtf8StringSchema(
|
||||
TERMINAL_LIMITS.maximumTitleBytes,
|
||||
1
|
||||
),
|
||||
state: terminalSessionStateSchema,
|
||||
shell: boundedUtf8StringSchema(
|
||||
TERMINAL_LIMITS.maximumShellLabelBytes,
|
||||
1
|
||||
),
|
||||
workingDirectory: boundedUtf8StringSchema(
|
||||
TERMINAL_LIMITS.maximumWorkingDirectoryBytes,
|
||||
1
|
||||
),
|
||||
size: terminalSizeSchema,
|
||||
lastSequence: terminalSequenceSchema,
|
||||
exit: terminalExitSchema.nullable(),
|
||||
error: terminalErrorSchema.nullable()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((snapshot, context) => {
|
||||
if (snapshot.state === 'exited' && snapshot.exit === null) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['exit'],
|
||||
message: 'Exited terminal snapshots require exit details'
|
||||
})
|
||||
}
|
||||
if (snapshot.state !== 'exited' && snapshot.exit !== null) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['exit'],
|
||||
message: 'Only exited terminal snapshots include exit details'
|
||||
})
|
||||
}
|
||||
})
|
||||
export type TerminalSnapshot = z.infer<
|
||||
typeof terminalSnapshotSchema
|
||||
>
|
||||
|
||||
const terminalEventBase = {
|
||||
sessionId: terminalSessionIdSchema,
|
||||
sequence: terminalEventSequenceSchema
|
||||
} as const
|
||||
|
||||
export const terminalEventSchema = z.discriminatedUnion('type', [
|
||||
z
|
||||
.object({
|
||||
...terminalEventBase,
|
||||
type: z.literal('output'),
|
||||
data: boundedUtf8StringSchema(
|
||||
TERMINAL_LIMITS.maximumEventBytes,
|
||||
1
|
||||
)
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
...terminalEventBase,
|
||||
type: z.literal('state'),
|
||||
state: terminalSessionStateSchema
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
...terminalEventBase,
|
||||
type: z.literal('exit'),
|
||||
exit: terminalExitSchema
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
...terminalEventBase,
|
||||
type: z.literal('error'),
|
||||
error: terminalErrorSchema
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
export type TerminalEvent = z.infer<typeof terminalEventSchema>
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
WORKBAR_APP_DEFINITIONS,
|
||||
WORKBAR_LIMITS,
|
||||
workbarAppDefinitionSchema,
|
||||
workbarLayoutPreferencesSchema,
|
||||
workbarTabInstanceSchema
|
||||
} from './workbar-contracts'
|
||||
|
||||
const taskId = '00000000-0000-4000-8000-000000000101'
|
||||
const terminalId = '00000000-0000-4000-8000-000000000102'
|
||||
const projectId = '00000000-0000-4000-8000-000000000201'
|
||||
|
||||
describe('workbar contracts', () => {
|
||||
it('freezes the first application catalog and instance policies', () => {
|
||||
expect(
|
||||
WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultOpen }) => ({
|
||||
id,
|
||||
instancePolicy,
|
||||
defaultOpen
|
||||
}))
|
||||
).toEqual([
|
||||
{ id: 'tasks', instancePolicy: 'single', defaultOpen: true },
|
||||
{ id: 'workspace', instancePolicy: 'single', defaultOpen: true },
|
||||
{ id: 'browser', instancePolicy: 'single', defaultOpen: true },
|
||||
{ id: 'results', instancePolicy: 'single', defaultOpen: true },
|
||||
{
|
||||
id: 'terminal',
|
||||
instancePolicy: 'multiple',
|
||||
defaultOpen: false
|
||||
}
|
||||
])
|
||||
for (const definition of WORKBAR_APP_DEFINITIONS) {
|
||||
expect(workbarAppDefinitionSchema.parse(definition)).toEqual(
|
||||
definition
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts an ordered layout with public target references', () => {
|
||||
const layout = {
|
||||
instances: [
|
||||
{ id: taskId, appId: 'tasks', title: '任务中心' },
|
||||
{
|
||||
id: terminalId,
|
||||
appId: 'terminal',
|
||||
title: '终端 · 项目',
|
||||
targetRef: { type: 'project', projectId }
|
||||
}
|
||||
],
|
||||
activeInstanceId: terminalId,
|
||||
expanded: true,
|
||||
dock: 'right',
|
||||
widthRatio: 0.35
|
||||
} as const
|
||||
|
||||
expect(workbarLayoutPreferencesSchema.parse(layout)).toEqual(layout)
|
||||
})
|
||||
|
||||
it('rejects unknown and sensitive target parameters', () => {
|
||||
for (const targetRef of [
|
||||
{ type: 'local', cwd: 'C:\\secret' },
|
||||
{ type: 'ssh-host', hostId: projectId, password: 'secret' },
|
||||
{
|
||||
type: 'project',
|
||||
projectId,
|
||||
environment: { MODEL_API_KEY: 'secret' }
|
||||
}
|
||||
]) {
|
||||
expect(
|
||||
workbarTabInstanceSchema.safeParse({
|
||||
id: terminalId,
|
||||
appId: 'terminal',
|
||||
title: '终端',
|
||||
targetRef
|
||||
}).success
|
||||
).toBe(false)
|
||||
}
|
||||
expect(
|
||||
workbarTabInstanceSchema.safeParse({
|
||||
id: taskId,
|
||||
appId: 'tasks',
|
||||
title: '任务中心',
|
||||
privateState: {}
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('enforces title, count, uniqueness, and active-instance bounds', () => {
|
||||
expect(
|
||||
workbarTabInstanceSchema.safeParse({
|
||||
id: terminalId,
|
||||
appId: 'terminal',
|
||||
title: 'x'.repeat(WORKBAR_LIMITS.maximumTitleBytes + 1),
|
||||
targetRef: { type: 'local' }
|
||||
}).success
|
||||
).toBe(false)
|
||||
|
||||
const task = { id: taskId, appId: 'tasks', title: '任务中心' }
|
||||
const base = {
|
||||
instances: [task],
|
||||
activeInstanceId: taskId,
|
||||
expanded: true,
|
||||
dock: 'right',
|
||||
widthRatio: 0.4
|
||||
}
|
||||
expect(
|
||||
workbarLayoutPreferencesSchema.safeParse({
|
||||
...base,
|
||||
instances: [task, { ...task, id: terminalId }]
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
workbarLayoutPreferencesSchema.safeParse({
|
||||
...base,
|
||||
activeInstanceId: terminalId
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
workbarLayoutPreferencesSchema.safeParse({
|
||||
...base,
|
||||
instances: Array.from(
|
||||
{ length: WORKBAR_LIMITS.maximumOpenInstances + 1 },
|
||||
(_, index) => ({
|
||||
id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
|
||||
appId: 'terminal',
|
||||
title: `终端 ${index}`,
|
||||
targetRef: { type: 'local' }
|
||||
})
|
||||
)
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,246 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const WORKBAR_LIMITS = {
|
||||
maximumOpenInstances: 32,
|
||||
maximumTitleBytes: 256,
|
||||
maximumDescriptionBytes: 512,
|
||||
maximumUnavailableReasonBytes: 512
|
||||
} as const
|
||||
|
||||
const boundedUtf8TextSchema = (
|
||||
maximumBytes: number,
|
||||
minimumBytes = 1
|
||||
): z.ZodType<string> =>
|
||||
z.string().refine(
|
||||
(value) => {
|
||||
const size = new TextEncoder().encode(value).byteLength
|
||||
return size >= minimumBytes && size <= maximumBytes
|
||||
},
|
||||
`Text must contain ${minimumBytes}-${maximumBytes} UTF-8 bytes`
|
||||
)
|
||||
|
||||
export const workbarAppIdSchema = z.enum([
|
||||
'tasks',
|
||||
'workspace',
|
||||
'browser',
|
||||
'results',
|
||||
'terminal'
|
||||
])
|
||||
export type WorkbarAppId = z.infer<typeof workbarAppIdSchema>
|
||||
|
||||
export const workbarInstancePolicySchema = z.enum([
|
||||
'single',
|
||||
'multiple'
|
||||
])
|
||||
export type WorkbarInstancePolicy = z.infer<
|
||||
typeof workbarInstancePolicySchema
|
||||
>
|
||||
|
||||
export const workbarDefaultContextSchema = z.enum([
|
||||
'application',
|
||||
'current-project'
|
||||
])
|
||||
export type WorkbarDefaultContext = z.infer<
|
||||
typeof workbarDefaultContextSchema
|
||||
>
|
||||
|
||||
export const workbarAvailabilitySchema = z.discriminatedUnion(
|
||||
'state',
|
||||
[
|
||||
z.object({ state: z.literal('available') }).strict(),
|
||||
z
|
||||
.object({
|
||||
state: z.literal('unavailable'),
|
||||
reason: boundedUtf8TextSchema(
|
||||
WORKBAR_LIMITS.maximumUnavailableReasonBytes
|
||||
),
|
||||
remedy: z.enum([
|
||||
'open-project-settings',
|
||||
'open-host-settings',
|
||||
'open-application-settings',
|
||||
'retry'
|
||||
])
|
||||
})
|
||||
.strict()
|
||||
]
|
||||
)
|
||||
export type WorkbarAvailability = z.infer<
|
||||
typeof workbarAvailabilitySchema
|
||||
>
|
||||
|
||||
export const workbarAppDefinitionSchema = z
|
||||
.object({
|
||||
id: workbarAppIdSchema,
|
||||
label: boundedUtf8TextSchema(WORKBAR_LIMITS.maximumTitleBytes),
|
||||
icon: workbarAppIdSchema,
|
||||
description: boundedUtf8TextSchema(
|
||||
WORKBAR_LIMITS.maximumDescriptionBytes
|
||||
),
|
||||
instancePolicy: workbarInstancePolicySchema,
|
||||
defaultContext: workbarDefaultContextSchema,
|
||||
defaultOpen: z.boolean(),
|
||||
availability: workbarAvailabilitySchema
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(definition) => definition.icon === definition.id,
|
||||
'The stable icon identifier must match the application identifier'
|
||||
)
|
||||
export type WorkbarAppDefinition = z.infer<
|
||||
typeof workbarAppDefinitionSchema
|
||||
>
|
||||
|
||||
export const WORKBAR_APP_DEFINITIONS = [
|
||||
{
|
||||
id: 'tasks',
|
||||
label: '任务中心',
|
||||
icon: 'tasks',
|
||||
description: '查看和管理当前任务。',
|
||||
instancePolicy: 'single',
|
||||
defaultContext: 'current-project',
|
||||
defaultOpen: true,
|
||||
availability: { state: 'available' }
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: '工作区',
|
||||
icon: 'workspace',
|
||||
description: '查看当前项目的工作区。',
|
||||
instancePolicy: 'single',
|
||||
defaultContext: 'current-project',
|
||||
defaultOpen: true,
|
||||
availability: { state: 'available' }
|
||||
},
|
||||
{
|
||||
id: 'browser',
|
||||
label: '浏览器',
|
||||
icon: 'browser',
|
||||
description: '浏览任务相关内容。',
|
||||
instancePolicy: 'single',
|
||||
defaultContext: 'application',
|
||||
defaultOpen: true,
|
||||
availability: { state: 'available' }
|
||||
},
|
||||
{
|
||||
id: 'results',
|
||||
label: '成果',
|
||||
icon: 'results',
|
||||
description: '查看任务生成的成果。',
|
||||
instancePolicy: 'single',
|
||||
defaultContext: 'current-project',
|
||||
defaultOpen: true,
|
||||
availability: { state: 'available' }
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
label: '终端',
|
||||
icon: 'terminal',
|
||||
description: '打开当前项目的用户终端。',
|
||||
instancePolicy: 'multiple',
|
||||
defaultContext: 'current-project',
|
||||
defaultOpen: false,
|
||||
availability: { state: 'available' }
|
||||
}
|
||||
] as const satisfies readonly WorkbarAppDefinition[]
|
||||
|
||||
export const workbarTargetRefSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('local') }).strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal('project'),
|
||||
projectId: z.string().uuid()
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
export type WorkbarTargetRef = z.infer<
|
||||
typeof workbarTargetRefSchema
|
||||
>
|
||||
|
||||
export const workbarTabInstanceSchema = z
|
||||
.object({
|
||||
id: z.string().uuid(),
|
||||
appId: workbarAppIdSchema,
|
||||
title: boundedUtf8TextSchema(WORKBAR_LIMITS.maximumTitleBytes),
|
||||
targetRef: workbarTargetRefSchema.optional()
|
||||
})
|
||||
.strict()
|
||||
.superRefine((instance, context) => {
|
||||
if (instance.appId === 'terminal' && !instance.targetRef) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['targetRef'],
|
||||
message: 'Terminal instances require a public target reference'
|
||||
})
|
||||
}
|
||||
})
|
||||
export type WorkbarTabInstance = z.infer<
|
||||
typeof workbarTabInstanceSchema
|
||||
>
|
||||
|
||||
const singleInstanceAppIds = new Set<WorkbarAppId>(
|
||||
WORKBAR_APP_DEFINITIONS.filter(
|
||||
(definition) => definition.instancePolicy === 'single'
|
||||
).map((definition) => definition.id)
|
||||
)
|
||||
|
||||
export const workbarLayoutPreferencesSchema = z
|
||||
.object({
|
||||
instances: z
|
||||
.array(workbarTabInstanceSchema)
|
||||
.max(WORKBAR_LIMITS.maximumOpenInstances),
|
||||
activeInstanceId: z.string().uuid().nullable(),
|
||||
expanded: z.boolean(),
|
||||
dock: z.literal('right'),
|
||||
widthRatio: z.number().finite().gt(0).lt(1)
|
||||
})
|
||||
.strict()
|
||||
.superRefine((layout, context) => {
|
||||
const instanceIds = new Set<string>()
|
||||
const seenSingleApps = new Set<WorkbarAppId>()
|
||||
|
||||
layout.instances.forEach((instance, index) => {
|
||||
if (instanceIds.has(instance.id)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['instances', index, 'id'],
|
||||
message: 'Workbar instance identifiers must be unique'
|
||||
})
|
||||
}
|
||||
instanceIds.add(instance.id)
|
||||
|
||||
if (singleInstanceAppIds.has(instance.appId)) {
|
||||
if (seenSingleApps.has(instance.appId)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['instances', index, 'appId'],
|
||||
message: 'Single-instance applications cannot be duplicated'
|
||||
})
|
||||
}
|
||||
seenSingleApps.add(instance.appId)
|
||||
}
|
||||
})
|
||||
|
||||
if (
|
||||
layout.activeInstanceId !== null &&
|
||||
!instanceIds.has(layout.activeInstanceId)
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['activeInstanceId'],
|
||||
message: 'The active workbar instance must be open'
|
||||
})
|
||||
}
|
||||
if (
|
||||
layout.instances.length > 0 &&
|
||||
layout.activeInstanceId === null
|
||||
) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['activeInstanceId'],
|
||||
message: 'An open workbar layout requires an active instance'
|
||||
})
|
||||
}
|
||||
})
|
||||
export type WorkbarLayoutPreferences = z.infer<
|
||||
typeof workbarLayoutPreferencesSchema
|
||||
>
|
||||
Reference in New Issue
Block a user