Remove verified dead declarations (#2714)
* chore: remove verified dead declarations Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * chore: narrow unused internal exports Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * chore: remove newly exposed dead helpers Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * chore: remove unused deep-link serializer Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * test: drop two tests that assert on copies of the code mainLayoutMobileSidebarMount read MainLayout.tsx and SessionSidebar.tsx as strings and asserted on source substrings down to exact indentation, so it failed on formatting rather than behaviour. useProjectSessionSelection.test reimplemented the hook's visitNodes logic inside the test file and asserted against that copy, so it could not observe the hook at all. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * test: repair sync suites that had rotted while unrunnable No runner executed packages/ui, so these drifted from the source unnoticed: two imported helpers that are no longer exported, one directory-store stub predated the session field routeMessage reads, and the WebSocket fake missed the mandatory url-token mint plus the close event the socket wrapper reads. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * test: stop the web suite failing on timeouts and a hand-copied mock The Git suites drive a real git binary, so the 5s default made a valid suite fail differently per run. The gitApiHttp mock listed ~70 export names by hand and fell behind the source; it now derives every stub from the real module, which the added shared-UI aliases make resolvable. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * test: run every suite from one command and in CI packages/ui (232 files) and packages/vscode (22) had no test script at all, CI ran neither, and 9 vscode files could never run because Node cannot resolve their extensionless TypeScript imports. Three electron files sat outside every script list, one of them importing vitest, which that package does not depend on. A runner gives each file its own process, since these suites keep module-level singletons and fail by load order when sharing one. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * chore: delete a superseded repro harness and a completed plan The issue-2638 harness needed lsof, overrode process.platform and spawned real servers, and nothing referenced it; event-stream/rebind.test.js now covers the same hub-pinned-to-the-old-port behaviour. The pairing v2 plan described relay and the pairing UI as out of scope, both of which shipped. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * docs: point at the theme tools and record the github barrel invariant convert-vscode-theme and harmonize-theme were referenced nowhere, so the theme-authoring reference now names them. The github barrel is loaded through await import('./index.js') and destructured per route, which no static report can see; documenting that is what stops the next cleanup from deleting it. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * test: repair merge drift in bridge and route-registry mocks upstream/main gained upsertProviderConfig on bridge-system-runtime and a PATCH scheduled-task route after this branch forked. Their test doubles were never updated to match: - bridge-system-runtime.test.js: add upsertProviderConfig to the opencodeConfig mock so the import resolves. - sse-routes.test.js: add app.patch to the route registry stub. --------- Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
co-authored by
Serhii Dziupin
parent
61533ed881
commit
86e6a2ae76
@@ -41,7 +41,7 @@ const languageContextField = StateField.define<ComposerLanguageContext>({
|
||||
},
|
||||
});
|
||||
|
||||
export const EMPTY_CONTEXT: ComposerLanguageContext = {
|
||||
const EMPTY_CONTEXT: ComposerLanguageContext = {
|
||||
inputMode: 'normal',
|
||||
knownAgentNames: new Set(),
|
||||
confirmedMentions: new Set(),
|
||||
@@ -90,8 +90,3 @@ export function composerLanguage(initial: ComposerLanguageContext = EMPTY_CONTEX
|
||||
decorationField,
|
||||
];
|
||||
}
|
||||
|
||||
/** The context currently in effect, for callers that need to read it back. */
|
||||
export function readLanguageContext(view: EditorView): ComposerLanguageContext {
|
||||
return view.state.field(languageContextField);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export const NATIVE_SELECTION_THEME_SPEC = {
|
||||
},
|
||||
};
|
||||
|
||||
export const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
|
||||
const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
|
||||
|
||||
/**
|
||||
* The native-selection arrangement, installed on every device: the theme
|
||||
|
||||
@@ -114,5 +114,3 @@ function matchMention(
|
||||
});
|
||||
return query === null ? null : { kind: 'mention', query };
|
||||
}
|
||||
|
||||
export type { FileMentionAutocompleteInputSource };
|
||||
|
||||
@@ -91,7 +91,7 @@ export function buildImagePasteInsertion(pastedText: string, citationText: strin
|
||||
* A single-line URL pasted over a selection becomes a markdown link rather
|
||||
* than replacing the selected text.
|
||||
*/
|
||||
export const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
|
||||
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
|
||||
|
||||
/**
|
||||
* Whether a pasted URL should wrap the selection as `[selected](url)`. A URL
|
||||
|
||||
@@ -54,7 +54,7 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
|
||||
projectColor ? PROJECT_COLOR_MAP[projectColor] ?? undefined : undefined;
|
||||
|
||||
/** A project's icon (custom image, configured icon, or a folder) plus its name. */
|
||||
export function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
|
||||
function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
|
||||
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
|
||||
const iconColor = getProjectIconColor(project.color);
|
||||
const fallbackIcon = projectIconName ? (
|
||||
|
||||
@@ -58,7 +58,7 @@ const toSelectionNode = (node: Node): SelectionNode | null => {
|
||||
};
|
||||
};
|
||||
|
||||
export const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => {
|
||||
const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => {
|
||||
return nodes
|
||||
.filter((node) => node.type === 'text' || !node.isCodeLineNumber)
|
||||
.map((node) => node.type === 'text'
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const mainLayoutSource = readFileSync(
|
||||
join(__dirname, '..', 'MainLayout.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
const sessionSidebarSource = readFileSync(
|
||||
join(__dirname, '..', '..', 'session', 'SessionSidebar.tsx'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)', () => {
|
||||
test('mobile SessionSidebar is not conditionally mounted on mobileLeftDrawerVisible', () => {
|
||||
const mobileSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar mobileVariant');
|
||||
expect(mobileSidebarIndex).toBeGreaterThan(-1);
|
||||
|
||||
const windowStart = Math.max(0, mobileSidebarIndex - 400);
|
||||
const precedingWindow = mainLayoutSource.slice(windowStart, mobileSidebarIndex);
|
||||
|
||||
expect(/\{\s*mobileLeftDrawerVisible\s*&&\s*\(/.test(precedingWindow)).toBe(false);
|
||||
|
||||
expect(precedingWindow.includes('pointer-events-none')).toBe(true);
|
||||
expect(mainLayoutSource.slice(mobileSidebarIndex, mobileSidebarIndex + 120)).toContain('isVisible={mobileLeftDrawerVisible}');
|
||||
});
|
||||
|
||||
test('desktop SessionSidebar is rendered inside Sidebar without drawer-visibility gating', () => {
|
||||
const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar isVisible={isSidebarOpen} />');
|
||||
expect(desktopSidebarIndex).toBeGreaterThan(-1);
|
||||
|
||||
const windowStart = Math.max(0, desktopSidebarIndex - 300);
|
||||
const precedingWindow = mainLayoutSource.slice(windowStart, desktopSidebarIndex);
|
||||
|
||||
expect(precedingWindow).toContain('<Sidebar');
|
||||
expect(/mobileLeftDrawerVisible\s*&&/.test(precedingWindow)).toBe(false);
|
||||
});
|
||||
|
||||
test('hidden sidebars disable render-only subscriptions and effects', () => {
|
||||
expect(sessionSidebarSource).toContain('useGitAllBranches(isVisible)');
|
||||
expect(sessionSidebarSource).toContain('useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY)');
|
||||
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isSessionSearchOpen');
|
||||
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isDesktopShellRuntime');
|
||||
expect(sessionSidebarSource).toContain('if (!isVisible) return EMPTY_STRING_ARRAY;');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
|
||||
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
|
||||
|
||||
export const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
|
||||
const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
|
||||
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
|
||||
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
||||
export const BASE_URL_PATTERN = /^https?:\/\//;
|
||||
export const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
|
||||
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
||||
const BASE_URL_PATTERN = /^https?:\/\//;
|
||||
const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
|
||||
|
||||
export type CustomProviderTranslator = (
|
||||
key: string,
|
||||
@@ -126,7 +126,7 @@ export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
|
||||
headers: [createHeaderRow()],
|
||||
});
|
||||
|
||||
export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
|
||||
function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
|
||||
const trimmed = apiKey.trim();
|
||||
if (!trimmed) {
|
||||
return {};
|
||||
|
||||
@@ -59,7 +59,7 @@ export const SETTINGS_SECTION_TITLE_CLASS =
|
||||
/** Split-pane sidebar panel title — same level as section titles. */
|
||||
export const SETTINGS_PANEL_TITLE_CLASS = SETTINGS_SECTION_TITLE_CLASS;
|
||||
/** L3 — control-group heading inside a section. */
|
||||
export const SETTINGS_GROUP_TITLE_CLASS =
|
||||
const SETTINGS_GROUP_TITLE_CLASS =
|
||||
'typography-settings-group-title text-foreground';
|
||||
/** L4 — field / control labels. */
|
||||
export const SETTINGS_FIELD_LABEL_CLASS =
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroup, SessionNode } from '../types';
|
||||
|
||||
let currentSessionId: string | null = null;
|
||||
let newSessionDraftOpen = false;
|
||||
let isNewWorktreeDialogOpen = false;
|
||||
|
||||
mock.module('@/stores/useUIStore', () => ({
|
||||
useUIStore: Object.assign(
|
||||
(selector: (state: { isNewWorktreeDialogOpen: boolean }) => unknown) =>
|
||||
selector({ isNewWorktreeDialogOpen }),
|
||||
{ getState: () => ({ isNewWorktreeDialogOpen }) },
|
||||
),
|
||||
}));
|
||||
|
||||
mock.module('@/sync/session-ui-store', () => ({
|
||||
useSessionUIStore: (selector: (state: {
|
||||
currentSessionId: string | null;
|
||||
newSessionDraft: { open: boolean };
|
||||
}) => unknown) => selector({
|
||||
currentSessionId,
|
||||
newSessionDraft: { open: newSessionDraftOpen },
|
||||
}),
|
||||
}));
|
||||
|
||||
const {
|
||||
resolveMissingProjectSessionSelection,
|
||||
ProjectSessionSelectionEffect,
|
||||
} = await import('./useProjectSessionSelection');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: simulate the projectSessionMeta computation from the hook
|
||||
// (same visitNodes logic as useProjectSessionSelection.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ProjectSection = {
|
||||
project: { id: string; normalizedPath: string };
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
function computeProjectMeta(projectSections: ProjectSection[]) {
|
||||
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
|
||||
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
|
||||
|
||||
const visitNodes = (
|
||||
projectId: string,
|
||||
projectRoot: string,
|
||||
fallbackDirectory: string | null,
|
||||
nodes: SessionNode[],
|
||||
) => {
|
||||
if (!metaByProject.has(projectId)) {
|
||||
metaByProject.set(projectId, new Map());
|
||||
}
|
||||
const projectMap = metaByProject.get(projectId)!;
|
||||
nodes.forEach((node) => {
|
||||
const sessionDirectory = (
|
||||
node.worktree?.path
|
||||
?? (node.session as Session & { directory?: string | null }).directory
|
||||
?? fallbackDirectory
|
||||
?? projectRoot
|
||||
).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
|
||||
projectMap.set(node.session.id, { directory: sessionDirectory });
|
||||
if (!firstSessionByProject.has(projectId)) {
|
||||
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
projectSections.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
|
||||
});
|
||||
});
|
||||
|
||||
return { metaByProject, firstSessionByProject };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeSession = (id: string, directory?: string): Session =>
|
||||
({ id, directory } as unknown as Session);
|
||||
|
||||
const rootSession1 = makeSession('root-session-1', '/workspace/project');
|
||||
const rootSession2 = makeSession('root-session-2', '/workspace/project');
|
||||
const worktreeSession1 = makeSession('wt-session-1', '/workspace/project-wt');
|
||||
|
||||
const project2Session1 = makeSession('project-2-session-1', '/workspace/project-2');
|
||||
const project2Session2 = makeSession('project-2-session-2', '/workspace/project-2');
|
||||
|
||||
const WORKTREE_PATH = '/workspace/project-wt';
|
||||
|
||||
// staleSections: root group only, no worktree group
|
||||
const staleSections: ProjectSection[] = [
|
||||
{
|
||||
project: { id: 'project-1', normalizedPath: '/workspace/project' },
|
||||
groups: [
|
||||
{
|
||||
id: 'root',
|
||||
label: 'Main',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: '/workspace/project',
|
||||
sessions: [
|
||||
{ session: rootSession1, children: [], worktree: null },
|
||||
{ session: rootSession2, children: [], worktree: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// updatedSections: includes the worktree group
|
||||
const updatedSections: ProjectSection[] = [
|
||||
{
|
||||
project: { id: 'project-1', normalizedPath: '/workspace/project' },
|
||||
groups: [
|
||||
{
|
||||
id: 'root',
|
||||
label: 'Main',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: '/workspace/project',
|
||||
sessions: [
|
||||
{ session: rootSession1, children: [], worktree: null },
|
||||
{ session: rootSession2, children: [], worktree: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wt-group',
|
||||
label: 'feature-branch',
|
||||
branch: 'feature-branch',
|
||||
description: 'Worktree at ' + WORKTREE_PATH,
|
||||
isMain: false,
|
||||
worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' },
|
||||
directory: WORKTREE_PATH,
|
||||
sessions: [
|
||||
{ session: worktreeSession1, children: [], worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// project-2Sections: separate project for project-switching tests
|
||||
const project2Sections: ProjectSection[] = [
|
||||
{
|
||||
project: { id: 'project-2', normalizedPath: '/workspace/project-2' },
|
||||
groups: [
|
||||
{
|
||||
id: 'root',
|
||||
label: 'Main',
|
||||
branch: null,
|
||||
description: null,
|
||||
isMain: true,
|
||||
worktree: null,
|
||||
directory: '/workspace/project-2',
|
||||
sessions: [
|
||||
{ session: project2Session1, children: [], worktree: null },
|
||||
{ session: project2Session2, children: [], worktree: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useProjectSessionSelection — worktree session click race', () => {
|
||||
test('stale projectSections (no worktree group) excludes worktree sessions from projectMap', () => {
|
||||
const { metaByProject } = computeProjectMeta(staleSections);
|
||||
const projectMap = metaByProject.get('project-1');
|
||||
|
||||
// Root sessions are present
|
||||
expect(projectMap?.has('root-session-1')).toBe(true);
|
||||
expect(projectMap?.has('root-session-2')).toBe(true);
|
||||
|
||||
// Worktree session is NOT present — this is what triggers the bug
|
||||
expect(projectMap?.has('wt-session-1')).toBe(false);
|
||||
});
|
||||
|
||||
test('stale data firstSessionByProject points to first root session, not worktree session', () => {
|
||||
const { firstSessionByProject } = computeProjectMeta(staleSections);
|
||||
|
||||
// Path C would fall back to firstSessionByProject, which is the first ROOT session
|
||||
const first = firstSessionByProject.get('project-1');
|
||||
expect(first?.id).toBe('root-session-1');
|
||||
expect(first?.id).not.toBe('wt-session-1');
|
||||
});
|
||||
|
||||
test('updated projectSections includes all sessions including worktree', () => {
|
||||
const { metaByProject } = computeProjectMeta(updatedSections);
|
||||
const projectMap = metaByProject.get('project-1');
|
||||
|
||||
expect(projectMap?.has('root-session-1')).toBe(true);
|
||||
expect(projectMap?.has('root-session-2')).toBe(true);
|
||||
expect(projectMap?.has('wt-session-1')).toBe(true);
|
||||
});
|
||||
|
||||
test('second click works correctly when projectSections is updated', () => {
|
||||
const { metaByProject } = computeProjectMeta(updatedSections);
|
||||
const projectMap = metaByProject.get('project-1')!;
|
||||
const currentSessionId = 'wt-session-1';
|
||||
|
||||
// After data arrives, Path A succeeds — no guard needed
|
||||
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
|
||||
expect(pathAHit).toBe(true);
|
||||
});
|
||||
|
||||
test('project switch: Path A succeeds when currentSessionId matches the new project', () => {
|
||||
const { metaByProject } = computeProjectMeta(project2Sections);
|
||||
const projectMap = metaByProject.get('project-2')!;
|
||||
const currentSessionId = 'project-2-session-1';
|
||||
|
||||
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
|
||||
expect(pathAHit).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMissingProjectSessionSelection', () => {
|
||||
test('A → B selects B remembered session when the current session is owned by A', () => {
|
||||
const projectBMap = new Map([
|
||||
['project-b-first-session', null],
|
||||
['project-b-remembered-session', null],
|
||||
]);
|
||||
|
||||
expect(resolveMissingProjectSessionSelection({
|
||||
activeProjectId: 'project-b',
|
||||
currentSessionId: 'stale-worktree-session-a',
|
||||
currentSessionOwnerProjectId: 'project-a',
|
||||
projectMap: projectBMap,
|
||||
metaByProject: new Map([['project-b', projectBMap]]),
|
||||
rememberedSessionId: 'project-b-remembered-session',
|
||||
fallbackSessionId: 'project-b-first-session',
|
||||
})).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' });
|
||||
});
|
||||
|
||||
test('A → B falls back to B first session when none is remembered', () => {
|
||||
const projectAMap = new Map([['project-a-session', null]]);
|
||||
const projectBMap = new Map([['project-b-first-session', null]]);
|
||||
const metaByProject = new Map([
|
||||
['project-a', projectAMap],
|
||||
['project-b', projectBMap],
|
||||
]);
|
||||
|
||||
expect(resolveMissingProjectSessionSelection({
|
||||
activeProjectId: 'project-b',
|
||||
currentSessionId: 'project-a-session',
|
||||
currentSessionOwnerProjectId: 'project-a',
|
||||
projectMap: projectBMap,
|
||||
metaByProject,
|
||||
rememberedSessionId: undefined,
|
||||
fallbackSessionId: 'project-b-first-session',
|
||||
})).toEqual({ kind: 'select-session', sessionId: 'project-b-first-session' });
|
||||
});
|
||||
|
||||
test('A → B opens a B-scoped draft when B is empty', () => {
|
||||
expect(resolveMissingProjectSessionSelection({
|
||||
activeProjectId: 'project-b',
|
||||
currentSessionId: 'project-a-session',
|
||||
currentSessionOwnerProjectId: 'project-a',
|
||||
projectMap: undefined,
|
||||
metaByProject: new Map([['project-a', new Map([['project-a-session', null]])]]),
|
||||
rememberedSessionId: undefined,
|
||||
fallbackSessionId: null,
|
||||
})).toEqual({ kind: 'open-draft' });
|
||||
});
|
||||
|
||||
test('preserves a same-project worktree session missing from a stale projectMap', () => {
|
||||
const projectMap = new Map([['root-session-1', null]]);
|
||||
const metaByProject = new Map([['project-1', projectMap]]);
|
||||
|
||||
expect(resolveMissingProjectSessionSelection({
|
||||
activeProjectId: 'project-1',
|
||||
currentSessionId: 'wt-session-1',
|
||||
currentSessionOwnerProjectId: 'project-1',
|
||||
projectMap,
|
||||
metaByProject,
|
||||
rememberedSessionId: undefined,
|
||||
fallbackSessionId: 'root-session-1',
|
||||
})).toEqual({ kind: 'preserve-current' });
|
||||
});
|
||||
|
||||
test('preserves an unknown session while worktree metadata may still be loading', () => {
|
||||
const projectMap = new Map([['root-session-1', null]]);
|
||||
const metaByProject = new Map([['project-1', projectMap]]);
|
||||
|
||||
expect(resolveMissingProjectSessionSelection({
|
||||
activeProjectId: 'project-1',
|
||||
currentSessionId: 'wt-session-1',
|
||||
currentSessionOwnerProjectId: null,
|
||||
projectMap,
|
||||
metaByProject,
|
||||
rememberedSessionId: undefined,
|
||||
fallbackSessionId: 'root-session-1',
|
||||
})).toEqual({ kind: 'preserve-current' });
|
||||
});
|
||||
|
||||
test('unknown ownership still switches when the session already appears under another project', () => {
|
||||
const projectAMap = new Map([['project-a-session', null]]);
|
||||
const projectBMap = new Map([
|
||||
['project-b-first-session', null],
|
||||
['project-b-remembered-session', null],
|
||||
]);
|
||||
const metaByProject = new Map([
|
||||
['project-a', projectAMap],
|
||||
['project-b', projectBMap],
|
||||
]);
|
||||
|
||||
expect(resolveMissingProjectSessionSelection({
|
||||
activeProjectId: 'project-b',
|
||||
currentSessionId: 'project-a-session',
|
||||
currentSessionOwnerProjectId: null,
|
||||
projectMap: projectBMap,
|
||||
metaByProject,
|
||||
rememberedSessionId: 'project-b-remembered-session',
|
||||
fallbackSessionId: 'project-b-first-session',
|
||||
})).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' });
|
||||
});
|
||||
|
||||
test('deleted or missing currentSessionId falls through to remembered/fallback selection', () => {
|
||||
const projectMap = new Map([['root-session-1', null]]);
|
||||
|
||||
expect(resolveMissingProjectSessionSelection({
|
||||
activeProjectId: 'project-1',
|
||||
currentSessionId: null,
|
||||
currentSessionOwnerProjectId: null,
|
||||
projectMap,
|
||||
metaByProject: new Map([['project-1', projectMap]]),
|
||||
rememberedSessionId: undefined,
|
||||
fallbackSessionId: 'root-session-1',
|
||||
})).toEqual({ kind: 'select-session', sessionId: 'root-session-1' });
|
||||
});
|
||||
|
||||
test('empty projects resolve to opening a draft', () => {
|
||||
expect(resolveMissingProjectSessionSelection({
|
||||
activeProjectId: 'empty-project',
|
||||
currentSessionId: 'some-session-id',
|
||||
currentSessionOwnerProjectId: null,
|
||||
projectMap: undefined,
|
||||
metaByProject: new Map<string, Map<string, null>>(),
|
||||
rememberedSessionId: undefined,
|
||||
fallbackSessionId: null,
|
||||
})).toEqual({ kind: 'open-draft' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook-level: ProjectSessionSelectionEffect recovery / preserve
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const installMinimalDom = () => {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = (name: string, value: unknown) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ElementStub {}
|
||||
const documentStub: Record<string, unknown> = {
|
||||
nodeType: 9,
|
||||
defaultView: globalThis,
|
||||
activeElement: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
const container = {
|
||||
nodeType: 1,
|
||||
tagName: 'DIV',
|
||||
nodeName: 'DIV',
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument: documentStub,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
documentStub.documentElement = container;
|
||||
documentStub.body = container;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
|
||||
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
|
||||
return {
|
||||
container: container as unknown as Element,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
type SelectionEffectProps = React.ComponentProps<typeof ProjectSessionSelectionEffect>;
|
||||
|
||||
const bothProjectSections: ProjectSection[] = [staleSections[0]!, project2Sections[0]!];
|
||||
|
||||
function mountSelectionEffect(initial: {
|
||||
activeProjectId: string;
|
||||
projectSections: ProjectSection[];
|
||||
sessionId: string | null;
|
||||
sessionOwnerBySessionId?: ReadonlyMap<string, { projectId: string }>;
|
||||
rememberedByProject?: Map<string, string>;
|
||||
}) {
|
||||
currentSessionId = initial.sessionId;
|
||||
newSessionDraftOpen = false;
|
||||
isNewWorktreeDialogOpen = false;
|
||||
|
||||
const sessionSelectCalls: Array<[string, string | null]> = [];
|
||||
const draftCalls: Array<{ selectedProjectId?: string | null; directoryOverride?: string | null } | undefined> = [];
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
|
||||
const props: SelectionEffectProps = {
|
||||
projectSections: initial.projectSections,
|
||||
activeProjectId: initial.activeProjectId,
|
||||
initialActiveSessionByProject: initial.rememberedByProject ?? new Map(),
|
||||
persistActiveSessionByProject: () => undefined,
|
||||
handleSessionSelect: (sessionId, sessionDirectory) => {
|
||||
sessionSelectCalls.push([sessionId, sessionDirectory]);
|
||||
},
|
||||
mobileVariant: false,
|
||||
openNewSessionDraft: (options) => {
|
||||
draftCalls.push(options);
|
||||
},
|
||||
setActiveMainTab: () => undefined,
|
||||
setSessionSwitcherOpen: () => undefined,
|
||||
sessionOwnerBySessionId: initial.sessionOwnerBySessionId,
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root.render(React.createElement(ProjectSessionSelectionEffect, props));
|
||||
});
|
||||
|
||||
return {
|
||||
sessionSelectCalls,
|
||||
draftCalls,
|
||||
rerender: (next: Partial<SelectionEffectProps> & { sessionId?: string | null }) => {
|
||||
const { sessionId, ...effectProps } = next;
|
||||
if (sessionId !== undefined) currentSessionId = sessionId;
|
||||
Object.assign(props, effectProps);
|
||||
act(() => {
|
||||
root.render(React.createElement(ProjectSessionSelectionEffect, props));
|
||||
});
|
||||
},
|
||||
teardown: () => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProjectSessionSelectionEffect — ownership recovery', () => {
|
||||
let teardown: (() => void) | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
teardown?.();
|
||||
teardown = null;
|
||||
currentSessionId = null;
|
||||
newSessionDraftOpen = false;
|
||||
isNewWorktreeDialogOpen = false;
|
||||
});
|
||||
|
||||
test('A → B with later foreign ownership selects B remembered session', () => {
|
||||
const missingASessionId = 'session-a-missing-from-maps';
|
||||
const mounted = mountSelectionEffect({
|
||||
activeProjectId: 'project-1',
|
||||
projectSections: bothProjectSections,
|
||||
sessionId: missingASessionId,
|
||||
sessionOwnerBySessionId: new Map([[missingASessionId, { projectId: 'project-1' }]]),
|
||||
rememberedByProject: new Map([['project-2', 'project-2-session-2']]),
|
||||
});
|
||||
teardown = mounted.teardown;
|
||||
|
||||
expect(mounted.sessionSelectCalls).toEqual([]);
|
||||
|
||||
mounted.rerender({
|
||||
activeProjectId: 'project-2',
|
||||
sessionOwnerBySessionId: new Map(),
|
||||
});
|
||||
expect(mounted.sessionSelectCalls).toEqual([]);
|
||||
|
||||
mounted.rerender({
|
||||
sessionOwnerBySessionId: new Map([[missingASessionId, { projectId: 'project-1' }]]),
|
||||
});
|
||||
expect(mounted.sessionSelectCalls).toEqual([
|
||||
['project-2-session-2', '/workspace/project-2'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('A → B with known foreign ownership selects B remembered session', () => {
|
||||
const mounted = mountSelectionEffect({
|
||||
activeProjectId: 'project-1',
|
||||
projectSections: bothProjectSections,
|
||||
sessionId: 'root-session-1',
|
||||
sessionOwnerBySessionId: new Map([['root-session-1', { projectId: 'project-1' }]]),
|
||||
rememberedByProject: new Map([['project-2', 'project-2-session-2']]),
|
||||
});
|
||||
teardown = mounted.teardown;
|
||||
|
||||
expect(mounted.sessionSelectCalls).toEqual([]);
|
||||
|
||||
mounted.rerender({ activeProjectId: 'project-2' });
|
||||
expect(mounted.sessionSelectCalls).toEqual([
|
||||
['project-2-session-2', '/workspace/project-2'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('stale same-project worktree selection stays put when ownership arrives', () => {
|
||||
const mounted = mountSelectionEffect({
|
||||
activeProjectId: 'project-1',
|
||||
projectSections: staleSections,
|
||||
sessionId: 'wt-session-1',
|
||||
sessionOwnerBySessionId: new Map(),
|
||||
rememberedByProject: new Map([['project-1', 'root-session-1']]),
|
||||
});
|
||||
teardown = mounted.teardown;
|
||||
|
||||
expect(mounted.sessionSelectCalls).toEqual([]);
|
||||
expect(mounted.draftCalls).toEqual([]);
|
||||
|
||||
mounted.rerender({
|
||||
sessionOwnerBySessionId: new Map([['wt-session-1', { projectId: 'project-1' }]]),
|
||||
});
|
||||
expect(mounted.sessionSelectCalls).toEqual([]);
|
||||
expect(mounted.draftCalls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -254,7 +254,6 @@ export const useProjectSessionSelection = (args: Args): void => {
|
||||
return next;
|
||||
});
|
||||
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
|
||||
|
||||
};
|
||||
|
||||
type ProjectSessionSelectionEffectProps = Omit<
|
||||
|
||||
@@ -28,7 +28,7 @@ const sessionDirectory = (session: Session | null | undefined): string | null =>
|
||||
return typeof directory === 'string' && directory.trim() ? directory : null;
|
||||
};
|
||||
|
||||
export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
|
||||
const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
|
||||
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
|
||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cva } from 'class-variance-authority';
|
||||
|
||||
/**
|
||||
* Single source of truth for every dropdown-style trigger surface in the app:
|
||||
@@ -34,6 +34,3 @@ export const dropdownTriggerVariants = cva(
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type DropdownTriggerVariantProps = VariantProps<typeof dropdownTriggerVariants>;
|
||||
export type DropdownTriggerSize = NonNullable<DropdownTriggerVariantProps['size']>;
|
||||
|
||||
@@ -52,7 +52,7 @@ interface PierreDiffViewerProps {
|
||||
* and enables touch-friendly line interactions. Re-exported so plain
|
||||
* <PierreFile> consumers (e.g. `MobileFilesSurface`) can inject the same.
|
||||
*/
|
||||
export const PIERRE_RUNTIME_BASE_CSS = `
|
||||
const PIERRE_RUNTIME_BASE_CSS = `
|
||||
:host {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-code);
|
||||
|
||||
Reference in New Issue
Block a user