fix(terminal): key project action state by one canonical directory
The terminal store keyed its directories by trimming trailing slashes, while the new sidebar activity indicator and the whole-server session grouping keyed the same folder with the project-action normalizer, which also rewrites backslashes. On Windows that split one project into two namespaces: the sidebar reconciled server sessions under "C:/repo" while the terminal panel and the project actions button worked under "C:\repo", so the running-action indicator never lit up, adopted tabs were duplicated and a stop recorded in one namespace did not guard reconciliation in the other. The project actions button also read the store map directly with a path normalized somewhere else, missing its own directory entry. All terminal directory keys now come from normalizeTerminalDirectory in lib/pathNormalization, and store reads go through getDirectoryState.
This commit is contained in:
@@ -226,13 +226,16 @@ export const ProjectActionsButton = ({
|
|||||||
contextHostDirectoryRef.current = contextHostDirectory;
|
contextHostDirectoryRef.current = contextHostDirectory;
|
||||||
}, [contextHostDirectory]);
|
}, [contextHostDirectory]);
|
||||||
|
|
||||||
|
// The store owns its directory key form; reading `sessions` directly with a
|
||||||
|
// project-action-normalized path misses the entry whenever the two spellings
|
||||||
|
// differ (Windows drive letters and separators).
|
||||||
const directoryTerminalState = useTerminalStore((state) => (
|
const directoryTerminalState = useTerminalStore((state) => (
|
||||||
normalizedDirectory ? state.sessions.get(normalizedDirectory) : undefined
|
normalizedDirectory ? state.getDirectoryState(normalizedDirectory) : undefined
|
||||||
));
|
));
|
||||||
|
|
||||||
const projectTerminalState = useTerminalStore((state) => (
|
const projectTerminalState = useTerminalStore((state) => (
|
||||||
normalizedProjectDirectory && normalizedProjectDirectory !== normalizedDirectory
|
normalizedProjectDirectory && normalizedProjectDirectory !== normalizedDirectory
|
||||||
? state.sessions.get(normalizedProjectDirectory)
|
? state.getDirectoryState(normalizedProjectDirectory)
|
||||||
: undefined
|
: undefined
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -1065,7 +1068,7 @@ export const ProjectActionsButton = ({
|
|||||||
const previewRun = previewAction ? projectActionRuns[toProjectActionRunKey(executionDirectoryFor(previewAction), previewAction.id)] : null;
|
const previewRun = previewAction ? projectActionRuns[toProjectActionRunKey(executionDirectoryFor(previewAction), previewAction.id)] : null;
|
||||||
const selectedRunPreviewUrl = useTerminalStore((state) => {
|
const selectedRunPreviewUrl = useTerminalStore((state) => {
|
||||||
if (!previewRun) return null;
|
if (!previewRun) return null;
|
||||||
return state.sessions.get(previewRun.directory)?.tabs.find((tab) => tab.id === previewRun.tabId)?.previewUrl ?? null;
|
return state.getDirectoryState(previewRun.directory)?.tabs.find((tab) => tab.id === previewRun.tabId)?.previewUrl ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) {
|
if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { normalizeProjectActionDirectory } from '@/lib/projectActions';
|
import { normalizeTerminalDirectory } from '@/lib/pathNormalization';
|
||||||
import { ACTIVE_PROJECT_ACTION_LIFECYCLES, useTerminalStore } from '@/stores/useTerminalStore';
|
import { ACTIVE_PROJECT_ACTION_LIFECYCLES, useTerminalStore } from '@/stores/useTerminalStore';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
/** A directory-scoped leaf subscription; output chunks do not rerender the indicator. */
|
/** A directory-scoped leaf subscription; output chunks do not rerender the indicator. */
|
||||||
export const DirectoryActionIndicator = ({ directory, className }: { directory: string; className?: string }) => {
|
export const DirectoryActionIndicator = ({ directory, className }: { directory: string; className?: string }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const key = normalizeProjectActionDirectory(directory);
|
const key = normalizeTerminalDirectory(directory);
|
||||||
const state = useTerminalStore(React.useCallback(store => store.sessions.get(key), [key]));
|
const state = useTerminalStore(React.useCallback(store => store.sessions.get(key), [key]));
|
||||||
const active = state?.tabs.some(tab => tab.purpose.type === 'project-action'
|
const active = state?.tabs.some(tab => tab.purpose.type === 'project-action'
|
||||||
&& tab.purpose.executionId !== null && ACTIVE_PROJECT_ACTION_LIFECYCLES.has(tab.lifecycle));
|
&& tab.purpose.executionId !== null && ACTIVE_PROJECT_ACTION_LIFECYCLES.has(tab.lifecycle));
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
|
|||||||
const targetDirectory = directory ?? null;
|
const targetDirectory = directory ?? null;
|
||||||
const terminalDirectory = targetDirectory || contextDirectory;
|
const terminalDirectory = targetDirectory || contextDirectory;
|
||||||
const hasExplicitTerminalTarget = targetDirectory !== null;
|
const hasExplicitTerminalTarget = targetDirectory !== null;
|
||||||
const directoryTerminalState = useTerminalStore((s) => terminalDirectory ? s.sessions.get(terminalDirectory) : undefined);
|
const directoryTerminalState = useTerminalStore((s) => terminalDirectory ? s.getDirectoryState(terminalDirectory) : undefined);
|
||||||
const terminalHydrated = useTerminalStore((s) => s.hasHydrated);
|
const terminalHydrated = useTerminalStore((s) => s.hasHydrated);
|
||||||
const ensureDirectory = useTerminalStore((s) => s.ensureDirectory);
|
const ensureDirectory = useTerminalStore((s) => s.ensureDirectory);
|
||||||
const createTab = useTerminalStore((s) => s.createTab);
|
const createTab = useTerminalStore((s) => s.createTab);
|
||||||
|
|||||||
@@ -27,3 +27,10 @@ export const normalizePath = (value?: string | null): string | null => {
|
|||||||
const stripped = replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced;
|
const stripped = replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced;
|
||||||
return stripped || null;
|
return stripped || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The directory key every terminal surface must agree on: the terminal store's
|
||||||
|
* map keys, the server `cwd` values grouped from a session listing, and the
|
||||||
|
* sidebar indicators. Empty when the value is not a usable path.
|
||||||
|
*/
|
||||||
|
export const normalizeTerminalDirectory = (value: string): string => normalizePath(value) ?? '';
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import type { TerminalAPI, TerminalHandlers } from './api/types';
|
import type { TerminalAPI, TerminalHandlers, TerminalServerSession } from './api/types';
|
||||||
|
import { normalizeTerminalDirectory } from './pathNormalization';
|
||||||
import {
|
import {
|
||||||
createProjectActionTerminalSession,
|
createProjectActionTerminalSession,
|
||||||
|
groupTerminalSessionsByDirectory,
|
||||||
normalizeProjectActionCommand,
|
normalizeProjectActionCommand,
|
||||||
reconcileTerminalSessionAuthority,
|
reconcileTerminalSessionAuthority,
|
||||||
stopProjectActionTerminalSession,
|
stopProjectActionTerminalSession,
|
||||||
@@ -301,3 +303,22 @@ test('cancelling a local request does not close a run adopted from another clien
|
|||||||
})).rejects.toThrow('PROJECT_ACTION_RUN_CANCELLED');
|
})).rejects.toThrow('PROJECT_ACTION_RUN_CANCELLED');
|
||||||
expect(closed).toEqual([]);
|
expect(closed).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a whole-server listing groups under the same directory keys the terminal store uses', () => {
|
||||||
|
// The sidebar reconciles by these keys while the terminal panel reconciles by
|
||||||
|
// the store's own key for the same folder; a Windows path must not split into
|
||||||
|
// two namespaces (`c:\\repo` from the server, `C:/repo` from the sidebar).
|
||||||
|
const session = (sessionId: string, cwd: string): TerminalServerSession => ({
|
||||||
|
sessionId, cwd, status: 'running', createdAt: 1, mode: 'command',
|
||||||
|
purpose: { type: 'project-action', actionId: 'dev', executionId: sessionId },
|
||||||
|
});
|
||||||
|
const grouped = groupTerminalSessionsByDirectory([
|
||||||
|
session('a', 'c:\\repo'),
|
||||||
|
session('b', 'C:/repo/'),
|
||||||
|
session('c', '/srv/app/'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect([...grouped.keys()].sort()).toEqual(['/srv/app', 'C:/repo']);
|
||||||
|
expect(grouped.get('C:/repo')?.map(entry => entry.sessionId)).toEqual(['a', 'b']);
|
||||||
|
expect(normalizeTerminalDirectory('c:\\repo')).toBe('C:/repo');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { normalizeProjectActionDirectory } from './projectActions';
|
import { normalizeTerminalDirectory as normalizeDirectory } from './pathNormalization';
|
||||||
import { getRuntimeKey } from './runtime-switch';
|
import { getRuntimeKey } from './runtime-switch';
|
||||||
import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
|
import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
|
||||||
|
|
||||||
type TerminalActionMutationRevisions = ReadonlyMap<string, number>;
|
type TerminalActionMutationRevisions = ReadonlyMap<string, number>;
|
||||||
|
|
||||||
const normalizeDirectory = (dir: string): string => {
|
|
||||||
let normalized = dir.trim();
|
|
||||||
while (normalized.length > 1 && normalized.endsWith('/')) {
|
|
||||||
normalized = normalized.slice(0, -1);
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command' | 'sessionId'>;
|
type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command' | 'sessionId'>;
|
||||||
|
|
||||||
type CreateProjectActionTerminalSessionOptions = {
|
type CreateProjectActionTerminalSessionOptions = {
|
||||||
@@ -256,10 +248,11 @@ export const reconcileTerminalSessionAuthority = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/** Groups a whole-server listing into the directory keys the terminal store uses. */
|
||||||
export const groupTerminalSessionsByDirectory = (sessions: TerminalServerSession[]): Map<string, TerminalServerSession[]> => {
|
export const groupTerminalSessionsByDirectory = (sessions: TerminalServerSession[]): Map<string, TerminalServerSession[]> => {
|
||||||
const groups = new Map<string, TerminalServerSession[]>();
|
const groups = new Map<string, TerminalServerSession[]>();
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const directory = normalizeProjectActionDirectory(session.cwd);
|
const directory = normalizeDirectory(session.cwd);
|
||||||
const group = groups.get(directory);
|
const group = groups.get(directory);
|
||||||
if (group) group.push(session);
|
if (group) group.push(session);
|
||||||
else groups.set(directory, [session]);
|
else groups.set(directory, [session]);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { TerminalAPI } from './api/types';
|
import type { TerminalAPI } from './api/types';
|
||||||
import { normalizeProjectActionDirectory } from './projectActions';
|
import { normalizeTerminalDirectory } from './pathNormalization';
|
||||||
import { groupTerminalSessionsByDirectory, reconcileTerminalSessionAuthority } from './projectActionTerminal';
|
import { groupTerminalSessionsByDirectory, reconcileTerminalSessionAuthority } from './projectActionTerminal';
|
||||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from './runtime-switch';
|
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from './runtime-switch';
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ export const observeTerminalSessions = (
|
|||||||
listener: Listener,
|
listener: Listener,
|
||||||
): (() => void) => {
|
): (() => void) => {
|
||||||
if (!terminal.listSessions) return () => {};
|
if (!terminal.listSessions) return () => {};
|
||||||
const key = normalizeProjectActionDirectory(directory);
|
const key = normalizeTerminalDirectory(directory);
|
||||||
let observation = observations.get(terminal);
|
let observation = observations.get(terminal);
|
||||||
if (!observation) {
|
if (!observation) {
|
||||||
const scopes = new Map<string, Scope>();
|
const scopes = new Map<string, Scope>();
|
||||||
|
|||||||
@@ -115,6 +115,10 @@ run monitor, and made Zustand persist rewrite the session-storage snapshot per c
|
|||||||
|
|
||||||
Invariants to preserve when editing:
|
Invariants to preserve when editing:
|
||||||
|
|
||||||
|
- Directory keys come from `normalizeTerminalDirectory` (`lib/pathNormalization.ts`) and
|
||||||
|
nothing else. Server `cwd` strings, sidebar project paths and the panel's own directory
|
||||||
|
all pass through it, so a folder has exactly one entry on every platform. Read `sessions`
|
||||||
|
through `getDirectoryState`, never by indexing the map with a path normalized elsewhere.
|
||||||
- Output actions (`appendToBuffer`, `replaceBuffer`) must leave `sessions` referentially
|
- Output actions (`appendToBuffer`, `replaceBuffer`) must leave `sessions` referentially
|
||||||
unchanged; only `buffers` and `nextChunkId` may change.
|
unchanged; only `buffers` and `nextChunkId` may change.
|
||||||
- Buffer entries are owned by their tab. `closeTab`, `removeDirectory`, `clearAll`, and
|
- Buffer entries are owned by their tab. `closeTab`, `removeDirectory`, `clearAll`, and
|
||||||
|
|||||||
@@ -602,3 +602,16 @@ test('a listing started before closing an action cannot resurrect its tab', () =
|
|||||||
store.reconcileServerSessions('/repo', [session], { startedActionMutationRevisions });
|
store.reconcileServerSessions('/repo', [session], { startedActionMutationRevisions });
|
||||||
expect(store.getDirectoryState('/repo')?.tabs.some(tab => tab.purpose.type === 'project-action')).toBe(false);
|
expect(store.getDirectoryState('/repo')?.tabs.some(tab => tab.purpose.type === 'project-action')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a Windows directory keys one store entry however the caller spells it', () => {
|
||||||
|
useTerminalStore.getState().clearAll();
|
||||||
|
const store = useTerminalStore.getState();
|
||||||
|
const session: TerminalServerSession = { sessionId: 'run', cwd: 'c:\\repo', status: 'running', createdAt: 1, purpose: { type: 'project-action', actionId: 'dev', executionId: 'run' } };
|
||||||
|
// The sidebar reconciles from the server's `cwd`; the terminal panel and the
|
||||||
|
// project actions button pass the directory they were handed.
|
||||||
|
store.reconcileServerSessions('c:\\repo', [session]);
|
||||||
|
|
||||||
|
expect(useTerminalStore.getState().sessions.size).toBe(1);
|
||||||
|
expect(directoryMayHaveActiveProjectAction(store.getDirectoryState('C:/repo/'))).toBe(true);
|
||||||
|
expect(store.getDirectoryState('C:\\repo')?.tabs).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { getSafeSessionStorage } from '@/stores/utils/safeStorage';
|
import { getSafeSessionStorage } from '@/stores/utils/safeStorage';
|
||||||
import type { TerminalServerSession } from '@/lib/api/types';
|
import type { TerminalServerSession } from '@/lib/api/types';
|
||||||
|
import { normalizeTerminalDirectory } from '@/lib/pathNormalization';
|
||||||
|
|
||||||
export interface TerminalChunk {
|
export interface TerminalChunk {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -228,13 +229,7 @@ const tabIdNumber = (tabId: string): number | null => {
|
|||||||
return Number.isFinite(num) ? num : null;
|
return Number.isFinite(num) ? num : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeDirectory(dir: string): string {
|
const normalizeDirectory = normalizeTerminalDirectory;
|
||||||
let normalized = dir.trim();
|
|
||||||
while (normalized.length > 1 && normalized.endsWith('/')) {
|
|
||||||
normalized = normalized.slice(0, -1);
|
|
||||||
}
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionMutationRevisionKey = (directory: string, actionId: string): string => `${directory}\u0000${actionId}`;
|
const actionMutationRevisionKey = (directory: string, actionId: string): string => `${directory}\u0000${actionId}`;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user