feat(sidebar): show running project actions by directory

This commit is contained in:
Bohdan Triapitsyn
2026-09-05 14:12:07 +03:00
parent 7a3244bab2
commit 4f1f9e6650
26 changed files with 310 additions and 54 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ export interface TerminalServerSession {
export interface TerminalAPI {
listShells?(): Promise<TerminalShellOption[]>;
/** Server-side sessions for a working directory; absent on runtimes without a server terminal list. */
/** Server-side sessions for a working directory, or all directories when cwd is empty; absent on runtimes without a server terminal list. */
listSessions?(cwd: string): Promise<TerminalServerSession[]>;
/** Marks the sessions as active so the server's idle sweep does not reap terminals an open client still shows. */
touchSessions?(sessionIds: string[]): Promise<void>;
+1
View File
@@ -3,6 +3,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'sessions.sidebar.projectAction.active': 'Projektaktion aktiv',
...settingsDict,
...linearIssuePickerI18n.de,
...linearPanelI18n.de,
+1
View File
@@ -3,6 +3,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'sessions.sidebar.projectAction.active': 'Project action active',
...settingsDict,
...linearIssuePickerI18n.en,
...linearPanelI18n.en,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Acción del proyecto en curso',
...settingsDict,
...linearIssuePickerI18n.es,
...linearPanelI18n.es,
+1
View File
@@ -3,6 +3,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'sessions.sidebar.projectAction.active': 'Action du projet en cours',
...settingsDict,
...linearIssuePickerI18n.fr,
...linearPanelI18n.fr,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'プロジェクトアクション実行中',
...settingsDict,
...linearIssuePickerI18n.ja,
...linearPanelI18n.ja,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': '프로젝트 작업 실행 중',
...settingsDict,
...linearIssuePickerI18n.ko,
...linearPanelI18n.ko,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Trwa wykonywanie akcji projektu',
...settingsDict,
...linearIssuePickerI18n.pl,
...linearPanelI18n.pl,
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Ação do projeto em execução',
...settingsDict,
...linearIssuePickerI18n['pt-BR'],
...linearPanelI18n['pt-BR'],
+1
View File
@@ -3,6 +3,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict = {
'sessions.sidebar.projectAction.active': 'Proje eylemi çalışıyor',
...settingsDict,
...linearIssuePickerI18n.tr,
...linearPanelI18n.tr,
+1
View File
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': 'Виконується дія проєкту',
...settingsDict,
...linearIssuePickerI18n.uk,
...linearPanelI18n.uk,
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': '项目操作正在运行',
...settingsDict,
...linearIssuePickerI18n['zh-CN'],
...linearPanelI18n['zh-CN'],
@@ -4,6 +4,7 @@ import { linearIssuePickerI18n } from './linear-issue-picker.i18n';
import { linearPanelI18n } from './linear-panel.i18n';
export const dict: Record<I18nKey, string> = {
'sessions.sidebar.projectAction.active': '專案操作正在執行',
...settingsDict,
...linearIssuePickerI18n['zh-TW'],
...linearPanelI18n['zh-TW'],
@@ -1,3 +1,4 @@
import { normalizeProjectActionDirectory } from './projectActions';
import { getRuntimeKey } from './runtime-switch';
import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
@@ -253,3 +254,15 @@ export const reconcileTerminalSessionAuthority = (
terminalFlights.set(flightKey, flight);
return flight;
};
export const groupTerminalSessionsByDirectory = (sessions: TerminalServerSession[]): Map<string, TerminalServerSession[]> => {
const groups = new Map<string, TerminalServerSession[]>();
for (const session of sessions) {
const directory = normalizeProjectActionDirectory(session.cwd);
const group = groups.get(directory);
if (group) group.push(session);
else groups.set(directory, [session]);
}
return groups;
};
@@ -49,10 +49,10 @@ test('visible consumers share one loop and discover a later peer run without int
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map(), result => first.push(result.sessions)));
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map(), result => second.push(result.sessions)));
await tick();
expect(source.reads).toEqual(['/repo']);
expect(source.reads).toEqual(['']);
source.setRecords([running]);
await new Promise(resolve => setTimeout(resolve, 5100));
expect(source.reads).toEqual(['/repo', '/repo']);
expect(source.reads).toEqual(['', '']);
expect(first.at(-1)).toEqual([running]);
expect(second.at(-1)).toEqual([running]);
}, 10000);
@@ -91,3 +91,36 @@ test('hidden and offline scopes stop reads, wake on recovery, and preserve state
await tick();
expect(source.reads).toHaveLength(3);
});
test('one global request serves 100 directories and an all-directory consumer', async () => {
const source = createTerminal();
source.setRecords([running]);
const seen = new Map<string, TerminalServerSession[]>();
for (let index = 0; index < 100; index += 1) {
const directory = index === 0 ? '/repo/' : `/project-${index}`;
cleanups.push(observeTerminalSessions(source.terminal, directory, () => new Map(), result => seen.set(directory, result.sessions)));
}
cleanups.push(observeTerminalSessions(source.terminal, '', () => new Map(), result => seen.set('all', result.sessions)));
await tick();
expect(source.reads).toEqual(['']);
expect(seen.get('/repo/')).toEqual([running]);
expect(seen.get('/project-1')).toEqual([]);
expect(seen.get('all')).toEqual([running]);
});
test('rejects a replaced runtime response and refreshes the new runtime', async () => {
const source = createTerminal();
let resolvePending: (sessions: TerminalServerSession[]) => void = () => {};
const pending = new Promise<TerminalServerSession[]>(resolve => { resolvePending = resolve; });
let calls = 0;
source.terminal.listSessions = () => ++calls === 1 ? pending : Promise.resolve([]);
const seen: TerminalServerSession[][] = [];
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map([['/repo\0build', 3]]), result => seen.push(result.sessions)));
await tick();
browser.dispatchEvent(new browser.CustomEvent('openchamber:runtime-endpoint-changed', { detail: {} }));
resolvePending([running]);
await tick();
expect(seen).toEqual([[]]);
expect(calls).toBe(2);
});
+58 -29
View File
@@ -1,15 +1,17 @@
import type { TerminalAPI } from './api/types';
import { reconcileTerminalSessionAuthority } from './projectActionTerminal';
import { normalizeProjectActionDirectory } from './projectActions';
import { groupTerminalSessionsByDirectory, reconcileTerminalSessionAuthority } from './projectActionTerminal';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from './runtime-switch';
const REFRESH_INTERVAL_MS = 5_000;
type AuthorityResult = NonNullable<Awaited<ReturnType<typeof reconcileTerminalSessionAuthority>>>;
type RevisionCapture = (directory: string) => ReadonlyMap<string, number>;
type Listener = (result: AuthorityResult) => void;
type Observation = { listeners: Set<Listener>; refresh: () => void; close: () => void };
const observations = new WeakMap<TerminalAPI, Map<string, Observation>>();
type Scope = { listeners: Set<Listener>; capture: RevisionCapture };
type Observation = { scopes: Map<string, Scope>; refresh: () => void; close: () => void };
const observations = new WeakMap<TerminalAPI, Observation>();
/** One visible-demand loop per adapter/directory, shared by the header and panel. */
/** One visible-demand loop per adapter. An empty directory observes all sessions. */
export const observeTerminalSessions = (
terminal: TerminalAPI,
directory: string,
@@ -17,35 +19,56 @@ export const observeTerminalSessions = (
listener: Listener,
): (() => void) => {
if (!terminal.listSessions) return () => {};
let directories = observations.get(terminal);
if (!directories) {
directories = new Map();
observations.set(terminal, directories);
}
let observation = directories.get(directory);
const key = normalizeProjectActionDirectory(directory);
let observation = observations.get(terminal);
if (!observation) {
const listeners = new Set<Listener>();
const scopes = new Map<string, Scope>();
let closed = false;
let inFlight = false;
let refreshAgain = false;
let queued = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let generation = 0;
const active = () => document.visibilityState !== 'hidden' && navigator.onLine !== false;
const clearTimer = () => { if (timer !== null) clearTimeout(timer); timer = null; };
const refresh = () => {
clearTimer();
if (closed || inFlight || !active()) return;
inFlight = true;
const startedGeneration = generation;
const runtimeKey = getRuntimeKey();
void reconcileTerminalSessionAuthority(terminal, directory, { captureStartedActionMutationRevisions })
.then(result => {
if (closed || !active()) return;
if (inFlight) { refreshAgain = true; return; }
// Mounting many directory consumers still starts one request.
if (queued) return;
queued = true;
queueMicrotask(() => {
queued = false;
if (closed || !active()) return;
inFlight = true;
refreshAgain = false;
const startedGeneration = generation;
const runtimeKey = getRuntimeKey();
const startedScopes = new Map(scopes);
void reconcileTerminalSessionAuthority(terminal, '', {
captureStartedActionMutationRevisions: () => {
const revisions = new Map<string, number>();
for (const [directory, scope] of startedScopes) {
for (const [action, revision] of scope.capture(directory)) revisions.set(action, revision);
}
return revisions;
},
}).then(result => {
if (closed || generation !== startedGeneration || runtimeKey !== getRuntimeKey() || !result) return;
for (const notify of listeners) notify(result);
})
.finally(() => {
const byDirectory = groupTerminalSessionsByDirectory(result.sessions);
for (const [directory, scope] of startedScopes) {
if (scopes.get(directory) !== scope) continue;
const sessions = directory ? byDirectory.get(directory) ?? [] : result.sessions;
for (const notify of scope.listeners) notify({ ...result, sessions });
}
}).finally(() => {
inFlight = false;
if (!closed && active()) timer = setTimeout(refresh, REFRESH_INTERVAL_MS);
if (closed || !active()) return;
if (refreshAgain) refresh();
else timer = setTimeout(refresh, REFRESH_INTERVAL_MS);
});
});
};
const runtimeChanged = () => { generation += 1; refresh(); };
window.addEventListener('focus', refresh);
@@ -54,7 +77,7 @@ export const observeTerminalSessions = (
document.addEventListener('visibilitychange', refresh);
const stopRuntimeListener = subscribeRuntimeEndpointChanged(runtimeChanged);
observation = {
listeners,
scopes,
refresh,
close: () => {
closed = true;
@@ -66,15 +89,21 @@ export const observeTerminalSessions = (
stopRuntimeListener();
},
};
directories.set(directory, observation);
observations.set(terminal, observation);
}
observation.listeners.add(listener);
observation.refresh();
let scope = observation.scopes.get(key);
if (!scope) {
scope = { listeners: new Set(), capture: captureStartedActionMutationRevisions };
observation.scopes.set(key, scope);
observation.refresh();
}
scope.listeners.add(listener);
return () => {
observation.listeners.delete(listener);
if (observation.listeners.size > 0) return;
scope.listeners.delete(listener);
if (scope.listeners.size > 0) return;
observation.scopes.delete(key);
if (observation.scopes.size > 0) return;
observation.close();
directories.delete(directory);
if (directories.size === 0) observations.delete(terminal);
observations.delete(terminal);
};
};