feat(sidebar): show running project actions by directory
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user