refactor: extract workspace terminal sessions, improve sidebar drag-and-drop with nestable lists, and consolidate tab presentation logic
This commit is contained in:
@@ -44,6 +44,10 @@ export type TerminalOutputSessionConsumeInput = {
|
||||
sequence: number;
|
||||
};
|
||||
|
||||
export type TerminalOutputSessionReadSnapshotInput = {
|
||||
terminalId: string | null;
|
||||
};
|
||||
|
||||
export class TerminalOutputSession {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly outputPump: TerminalOutputPump;
|
||||
@@ -132,6 +136,10 @@ export class TerminalOutputSession {
|
||||
this.outputPump.append(input);
|
||||
}
|
||||
|
||||
readSnapshot(input: TerminalOutputSessionReadSnapshotInput): string {
|
||||
return this.outputPump.readSnapshot(input);
|
||||
}
|
||||
|
||||
clearTerminal(input: { terminalId: string }): void {
|
||||
this.outputPump.clearTerminal(input);
|
||||
if (this.state.selectedTerminalId !== input.terminalId) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type TerminalStreamControllerAttachPayload,
|
||||
type TerminalStreamControllerChunk,
|
||||
type TerminalStreamControllerClient,
|
||||
type TerminalStreamControllerResumeOffsets,
|
||||
type TerminalStreamControllerStatus,
|
||||
} from "./terminal-stream-controller";
|
||||
|
||||
@@ -112,6 +113,7 @@ class FakeTerminalStreamClient implements TerminalStreamControllerClient {
|
||||
|
||||
function createControllerHarness(input?: {
|
||||
client?: FakeTerminalStreamClient;
|
||||
resumeOffsets?: TerminalStreamControllerResumeOffsets;
|
||||
}): {
|
||||
client: FakeTerminalStreamClient;
|
||||
chunks: Array<{ terminalId: string; text: string }>;
|
||||
@@ -127,6 +129,7 @@ function createControllerHarness(input?: {
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => ({ rows: 24, cols: 80 }),
|
||||
resumeOffsets: input?.resumeOffsets,
|
||||
onChunk: (chunk) => {
|
||||
chunks.push({
|
||||
terminalId: chunk.terminalId,
|
||||
@@ -518,4 +521,69 @@ describe("terminal-stream-controller", () => {
|
||||
text: "live",
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses external resume offsets across controller recreation", async () => {
|
||||
const client = new FakeTerminalStreamClient();
|
||||
const resumeOffsetByTerminalId = new Map<string, number>();
|
||||
const resumeOffsets: TerminalStreamControllerResumeOffsets = {
|
||||
get: ({ terminalId }) => resumeOffsetByTerminalId.get(terminalId),
|
||||
set: ({ terminalId, offset }) => {
|
||||
resumeOffsetByTerminalId.set(terminalId, offset);
|
||||
},
|
||||
clear: ({ terminalId }) => {
|
||||
resumeOffsetByTerminalId.delete(terminalId);
|
||||
},
|
||||
prune: ({ terminalIds }) => {
|
||||
const terminalIdSet = new Set(terminalIds);
|
||||
for (const terminalId of Array.from(resumeOffsetByTerminalId.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
resumeOffsetByTerminalId.delete(terminalId);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
client.nextAttachResponses.push({
|
||||
streamId: 81,
|
||||
currentOffset: 0,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const firstHarness = createControllerHarness({
|
||||
client,
|
||||
resumeOffsets,
|
||||
});
|
||||
firstHarness.controller.setTerminal({ terminalId: "term-shared-resume" });
|
||||
await flushAsyncWork();
|
||||
|
||||
client.emitChunk({
|
||||
streamId: 81,
|
||||
offset: 0,
|
||||
endOffset: 2,
|
||||
data: "ok",
|
||||
});
|
||||
await flushAsyncWork();
|
||||
firstHarness.controller.dispose();
|
||||
|
||||
client.nextAttachResponses.push({
|
||||
streamId: 82,
|
||||
currentOffset: 2,
|
||||
reset: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const secondHarness = createControllerHarness({
|
||||
client,
|
||||
resumeOffsets,
|
||||
});
|
||||
secondHarness.controller.setTerminal({ terminalId: "term-shared-resume" });
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(client.attachCalls.at(-1)?.options).toEqual({
|
||||
resumeOffset: 2,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
getTerminalAttachRetryDelayMs,
|
||||
getTerminalResumeOffset,
|
||||
isTerminalAttachRetryableError,
|
||||
type TerminalResumeOffsetStore,
|
||||
updateTerminalResumeOffset,
|
||||
waitForDuration,
|
||||
withPromiseTimeout,
|
||||
@@ -51,9 +52,15 @@ export type TerminalStreamControllerStatus = {
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type TerminalStreamControllerResumeOffsets = TerminalResumeOffsetStore & {
|
||||
clear: (input: { terminalId: string }) => void;
|
||||
prune: (input: { terminalIds: string[] }) => void;
|
||||
};
|
||||
|
||||
export type TerminalStreamControllerOptions = {
|
||||
client: TerminalStreamControllerClient;
|
||||
getPreferredSize: () => TerminalStreamControllerSize | null;
|
||||
resumeOffsets?: TerminalStreamControllerResumeOffsets;
|
||||
onChunk: (input: { terminalId: string; text: string; replay: boolean }) => void;
|
||||
onReset?: (input: { terminalId: string }) => void;
|
||||
onStatusChange?: (status: TerminalStreamControllerStatus) => void;
|
||||
@@ -84,8 +91,8 @@ const DEFAULT_ATTACH_TIMEOUT_MS = 12_000;
|
||||
const DEFAULT_RECONNECT_ERROR_MESSAGE = "Terminal stream ended. Reconnecting…";
|
||||
|
||||
export class TerminalStreamController {
|
||||
private readonly resumeOffsetByTerminalId = new Map<string, number>();
|
||||
private readonly exitedStreamsWhileAttaching = new Set<number>();
|
||||
private readonly internalResumeOffsetByTerminalId = new Map<string, number>();
|
||||
private selectedTerminalId: string | null = null;
|
||||
private activeStream: TerminalStreamControllerActiveStream | null = null;
|
||||
private attachGeneration = 0;
|
||||
@@ -201,10 +208,14 @@ export class TerminalStreamController {
|
||||
}
|
||||
|
||||
pruneResumeOffsets(input: { terminalIds: string[] }): void {
|
||||
if (this.options.resumeOffsets) {
|
||||
this.options.resumeOffsets.prune(input);
|
||||
return;
|
||||
}
|
||||
const terminalIdSet = new Set(input.terminalIds);
|
||||
for (const terminalId of Array.from(this.resumeOffsetByTerminalId.keys())) {
|
||||
for (const terminalId of Array.from(this.internalResumeOffsetByTerminalId.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
this.resumeOffsetByTerminalId.delete(terminalId);
|
||||
this.internalResumeOffsetByTerminalId.delete(terminalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,7 +228,7 @@ export class TerminalStreamController {
|
||||
this.attachGeneration += 1;
|
||||
this.selectedTerminalId = null;
|
||||
void this.detachActiveStream({ shouldDetach: true });
|
||||
this.resumeOffsetByTerminalId.clear();
|
||||
this.internalResumeOffsetByTerminalId.clear();
|
||||
this.updateStatus({
|
||||
terminalId: null,
|
||||
streamId: null,
|
||||
@@ -260,7 +271,7 @@ export class TerminalStreamController {
|
||||
const preferredSize = this.options.getPreferredSize();
|
||||
const resumeOffset = getTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
resumeOffsetStore: this.getResumeOffsetStore(),
|
||||
});
|
||||
const attachPayload = await withTimeout({
|
||||
promise: this.options.client.attachTerminalStream(input.terminalId, {
|
||||
@@ -276,7 +287,7 @@ export class TerminalStreamController {
|
||||
updateTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
offset: attachPayload.currentOffset,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
resumeOffsetStore: this.getResumeOffsetStore(),
|
||||
});
|
||||
|
||||
if (!this.isAttachGenerationCurrent({ generation: input.generation, terminalId: input.terminalId })) {
|
||||
@@ -324,7 +335,7 @@ export class TerminalStreamController {
|
||||
}
|
||||
|
||||
if (attachPayload.reset) {
|
||||
this.resumeOffsetByTerminalId.delete(input.terminalId);
|
||||
this.options.resumeOffsets?.clear({ terminalId: input.terminalId });
|
||||
this.options.onReset?.({ terminalId: input.terminalId });
|
||||
}
|
||||
|
||||
@@ -492,7 +503,7 @@ export class TerminalStreamController {
|
||||
updateTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
offset: chunkEndOffset,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
resumeOffsetStore: this.getResumeOffsetStore(),
|
||||
});
|
||||
|
||||
const text = input.decoder.decode(input.chunk.data, { stream: true });
|
||||
@@ -541,7 +552,7 @@ export class TerminalStreamController {
|
||||
updateTerminalResumeOffset({
|
||||
terminalId: input.terminalId,
|
||||
offset: input.expectedOffset,
|
||||
resumeOffsetByTerminalId: this.resumeOffsetByTerminalId,
|
||||
resumeOffsetStore: this.getResumeOffsetStore(),
|
||||
});
|
||||
|
||||
this.attachGeneration += 1;
|
||||
@@ -634,4 +645,16 @@ export class TerminalStreamController {
|
||||
});
|
||||
this.options.onStatusChange?.(status);
|
||||
}
|
||||
|
||||
private getResumeOffsetStore(): TerminalResumeOffsetStore {
|
||||
if (this.options.resumeOffsets) {
|
||||
return this.options.resumeOffsets;
|
||||
}
|
||||
return {
|
||||
get: ({ terminalId }) => this.internalResumeOffsetByTerminalId.get(terminalId),
|
||||
set: ({ terminalId, offset }) => {
|
||||
this.internalResumeOffsetByTerminalId.set(terminalId, offset);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getWorkspaceTerminalSession,
|
||||
releaseWorkspaceTerminalSession,
|
||||
retainWorkspaceTerminalSession,
|
||||
} from "./workspace-terminal-session";
|
||||
|
||||
describe("workspace-terminal-session", () => {
|
||||
it("returns the same workspace session instance for the same scope", () => {
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-a",
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-a",
|
||||
maxOutputChars: 50,
|
||||
});
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("preserves resume offsets across repeated lookups", () => {
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-resume",
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
first.resumeOffsets.set({
|
||||
terminalId: "term-1",
|
||||
offset: 42,
|
||||
});
|
||||
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey: "workspace-resume",
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
|
||||
expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBe(42);
|
||||
});
|
||||
|
||||
it("evicts workspace terminal session state when the workspace retain count returns to zero", () => {
|
||||
const scopeKey = "workspace-release";
|
||||
const first = getWorkspaceTerminalSession({
|
||||
scopeKey,
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
first.resumeOffsets.set({
|
||||
terminalId: "term-1",
|
||||
offset: 128,
|
||||
});
|
||||
|
||||
retainWorkspaceTerminalSession({ scopeKey });
|
||||
releaseWorkspaceTerminalSession({ scopeKey });
|
||||
|
||||
const second = getWorkspaceTerminalSession({
|
||||
scopeKey,
|
||||
maxOutputChars: 1_000,
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.resumeOffsets.get({ terminalId: "term-1" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
getTerminalOutputSession,
|
||||
releaseTerminalOutputSession,
|
||||
retainTerminalOutputSession,
|
||||
type TerminalOutputSession,
|
||||
} from "./terminal-output-session";
|
||||
|
||||
export type WorkspaceTerminalResumeOffsets = {
|
||||
get: (input: { terminalId: string }) => number | undefined;
|
||||
set: (input: { terminalId: string; offset: number }) => void;
|
||||
clear: (input: { terminalId: string }) => void;
|
||||
prune: (input: { terminalIds: string[] }) => void;
|
||||
};
|
||||
|
||||
export type WorkspaceTerminalSession = {
|
||||
scopeKey: string;
|
||||
outputSession: TerminalOutputSession;
|
||||
resumeOffsets: WorkspaceTerminalResumeOffsets;
|
||||
};
|
||||
|
||||
type WorkspaceTerminalSessionRecord = {
|
||||
resumeOffsetByTerminalId: Map<string, number>;
|
||||
session: WorkspaceTerminalSession;
|
||||
};
|
||||
|
||||
const sessionsByScopeKey = new Map<string, WorkspaceTerminalSessionRecord>();
|
||||
const refCountByScopeKey = new Map<string, number>();
|
||||
|
||||
function createResumeOffsets(input: {
|
||||
resumeOffsetByTerminalId: Map<string, number>;
|
||||
}): WorkspaceTerminalResumeOffsets {
|
||||
return {
|
||||
get: ({ terminalId }) => {
|
||||
const offset = input.resumeOffsetByTerminalId.get(terminalId);
|
||||
if (typeof offset !== "number" || !Number.isFinite(offset)) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.max(0, Math.floor(offset));
|
||||
},
|
||||
set: ({ terminalId, offset }) => {
|
||||
if (!Number.isFinite(offset)) {
|
||||
return;
|
||||
}
|
||||
input.resumeOffsetByTerminalId.set(terminalId, Math.max(0, Math.floor(offset)));
|
||||
},
|
||||
clear: ({ terminalId }) => {
|
||||
input.resumeOffsetByTerminalId.delete(terminalId);
|
||||
},
|
||||
prune: ({ terminalIds }) => {
|
||||
const terminalIdSet = new Set(terminalIds);
|
||||
for (const terminalId of Array.from(input.resumeOffsetByTerminalId.keys())) {
|
||||
if (!terminalIdSet.has(terminalId)) {
|
||||
input.resumeOffsetByTerminalId.delete(terminalId);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspaceTerminalSession(input: {
|
||||
scopeKey: string;
|
||||
maxOutputChars: number;
|
||||
}): WorkspaceTerminalSession {
|
||||
const existing = sessionsByScopeKey.get(input.scopeKey);
|
||||
if (existing) {
|
||||
return existing.session;
|
||||
}
|
||||
|
||||
const resumeOffsetByTerminalId = new Map<string, number>();
|
||||
const session: WorkspaceTerminalSession = {
|
||||
scopeKey: input.scopeKey,
|
||||
outputSession: getTerminalOutputSession({
|
||||
scopeKey: input.scopeKey,
|
||||
maxOutputChars: input.maxOutputChars,
|
||||
}),
|
||||
resumeOffsets: createResumeOffsets({
|
||||
resumeOffsetByTerminalId,
|
||||
}),
|
||||
};
|
||||
|
||||
sessionsByScopeKey.set(input.scopeKey, {
|
||||
resumeOffsetByTerminalId,
|
||||
session,
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
export function retainWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
||||
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||
refCountByScopeKey.set(input.scopeKey, current + 1);
|
||||
retainTerminalOutputSession(input);
|
||||
}
|
||||
|
||||
export function releaseWorkspaceTerminalSession(input: { scopeKey: string }): void {
|
||||
releaseTerminalOutputSession(input);
|
||||
const current = refCountByScopeKey.get(input.scopeKey) ?? 0;
|
||||
if (current > 1) {
|
||||
refCountByScopeKey.set(input.scopeKey, current - 1);
|
||||
return;
|
||||
}
|
||||
refCountByScopeKey.delete(input.scopeKey);
|
||||
sessionsByScopeKey.delete(input.scopeKey);
|
||||
}
|
||||
Reference in New Issue
Block a user