perf(sidebar): index session ownership and narrow live subscriptions
Replace repeated project-by-session directory matching across sidebar hooks with a shared ownership index that resolves each unique directory once and exposes direct project and folder-scope buckets. Gate destructive folder reconciliation on authoritative session data and topology readiness, preserve last-known worktrees after discovery failures, and retain nested-project, VS Code, active/archive dedupe, and Windows drive-root semantics. Narrow cross-directory subscriptions to session and status slices so streaming deltas no longer trigger global aggregation. Reuse a cached session ID index for permission lineage checks instead of rebuilding it on every session switch. On the reported 15-project, 67-worktree, 14,561-session shape, ownership indexing averages 3.81 ms versus roughly 450 ms for the cache-only hotfix. Validation: 28 targeted tests, UI type-check, UI lint, and dead-code analysis.
This commit is contained in:
@@ -31,6 +31,7 @@ import { useSidebarPersistence } from './sidebar/hooks/useSidebarPersistence';
|
||||
import { useProjectRepoStatus } from './sidebar/hooks/useProjectRepoStatus';
|
||||
import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
|
||||
import { useSessionFolderCleanup } from './sidebar/hooks/useSessionFolderCleanup';
|
||||
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
|
||||
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||
@@ -319,7 +320,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const sync = useSync();
|
||||
const liveSessions = useAllLiveSessions();
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded);
|
||||
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
|
||||
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||
@@ -418,6 +418,11 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
.join('|'),
|
||||
[projects],
|
||||
);
|
||||
const [resolvedWorktreeTopologyKey, setResolvedWorktreeTopologyKey] = React.useState<string | null>(
|
||||
isVSCode ? projectWorktreeDiscoveryKey : null,
|
||||
);
|
||||
const isWorktreeTopologyLoading = !isVSCode && resolvedWorktreeTopologyKey !== projectWorktreeDiscoveryKey;
|
||||
const [unresolvedWorktreeProjectPaths, setUnresolvedWorktreeProjectPaths] = React.useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
const initialGlobalSessionsRefreshStartedRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
@@ -428,19 +433,22 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
void refreshGlobalSessions(syncSessionsSnapshotRef.current);
|
||||
}, []);
|
||||
|
||||
// Tracks the last project list we already kicked off discovery for.
|
||||
// A re-mount with the same project set shouldn't fan out another
|
||||
// burst of `checkIsGitRepository` / `listProjectWorktrees` calls.
|
||||
const discoveredProjectsRef = React.useRef<string>('');
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const discoverWorktrees = async () => {
|
||||
const projectEntries = useProjectsStore.getState().projects;
|
||||
if (projectEntries.length === 0) return;
|
||||
if (projectEntries.length === 0 || isVSCode) {
|
||||
if (!cancelled) {
|
||||
setUnresolvedWorktreeProjectPaths(new Set());
|
||||
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
||||
const allWorktrees: WorktreeMetadata[] = [];
|
||||
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
||||
const worktreesByProject = new Map(currentByProject);
|
||||
const unresolvedProjectPaths = new Set<string>();
|
||||
|
||||
// Constrain fanout: previously `Promise.all(projects.map(...))` could
|
||||
// spawn dozens of concurrent `git worktree list` and
|
||||
@@ -464,13 +472,20 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
// PR/render paths downstream can read isGitRepo for free.
|
||||
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
|
||||
const isGitRepo = cachedIsGitRepo ?? await checkIsGitRepository(projectPath);
|
||||
if (!isGitRepo) continue;
|
||||
if (!isGitRepo) {
|
||||
worktreesByProject.delete(projectPath);
|
||||
continue;
|
||||
}
|
||||
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||
if (cancelled || worktrees.length === 0) continue;
|
||||
worktreesByProject.set(projectPath, worktrees);
|
||||
allWorktrees.push(...worktrees);
|
||||
if (cancelled) return;
|
||||
if (worktrees.length === 0) {
|
||||
worktreesByProject.delete(projectPath);
|
||||
} else {
|
||||
worktreesByProject.set(projectPath, worktrees);
|
||||
}
|
||||
} catch {
|
||||
// ignore discovery errors
|
||||
// Keep last-known worktrees when a project is temporarily unavailable.
|
||||
unresolvedProjectPaths.add(projectPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -478,27 +493,31 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const activeProjectPaths = new Set(projectEntries.map((project) => normalizePath(project.path)).filter(Boolean));
|
||||
for (const projectPath of worktreesByProject.keys()) {
|
||||
if (!activeProjectPaths.has(projectPath)) {
|
||||
worktreesByProject.delete(projectPath);
|
||||
}
|
||||
}
|
||||
const allWorktrees = [...worktreesByProject.values()].flat();
|
||||
|
||||
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
|
||||
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
||||
if (!worktreeMapsEqual(worktreesByProject, currentByProject)) {
|
||||
useSessionUIStore.setState({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: worktreesByProject,
|
||||
});
|
||||
}
|
||||
setUnresolvedWorktreeProjectPaths(unresolvedProjectPaths);
|
||||
setResolvedWorktreeTopologyKey(projectWorktreeDiscoveryKey);
|
||||
};
|
||||
|
||||
// Skip if we already discovered worktrees for this exact project set.
|
||||
if (discoveredProjectsRef.current === projectWorktreeDiscoveryKey) {
|
||||
return;
|
||||
}
|
||||
discoveredProjectsRef.current = projectWorktreeDiscoveryKey;
|
||||
void discoverWorktrees();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectWorktreeDiscoveryKey]);
|
||||
}, [isVSCode, projectWorktreeDiscoveryKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let refreshTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -943,18 +962,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
);
|
||||
|
||||
const projectSessionDirectories = React.useMemo(() => {
|
||||
const directories = new Set<string>();
|
||||
normalizedProjects.forEach((project) => {
|
||||
if (project.normalizedPath) directories.add(project.normalizedPath);
|
||||
if (isVSCode) {
|
||||
return;
|
||||
const directories = new Set(normalizedProjects.map((project) => project.normalizedPath));
|
||||
if (!isVSCode) {
|
||||
for (const worktrees of availableWorktreesByProject.values()) {
|
||||
for (const worktree of worktrees) {
|
||||
const directory = normalizePath(worktree.path);
|
||||
if (directory) directories.add(directory);
|
||||
}
|
||||
}
|
||||
const worktrees = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
worktrees.forEach((worktree) => {
|
||||
const directory = normalizePath(worktree.path);
|
||||
if (directory) directories.add(directory);
|
||||
});
|
||||
});
|
||||
}
|
||||
return [...directories].sort();
|
||||
}, [availableWorktreesByProject, isVSCode, normalizedProjects]);
|
||||
|
||||
@@ -994,32 +1010,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
|
||||
const isSessionsLoading = useSessionUIStore((state) => state.isLoading);
|
||||
const sessionOwnership = React.useMemo(
|
||||
() => createSessionOwnershipIndex(sessions, normalizedProjects, availableWorktreesByProject, isVSCode, archivedSessions),
|
||||
[archivedSessions, availableWorktreesByProject, isVSCode, normalizedProjects, sessions],
|
||||
);
|
||||
useSessionFolderCleanup({
|
||||
isSessionsLoading,
|
||||
hasLoadedGlobalSessions,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
normalizedProjects,
|
||||
isVSCode,
|
||||
ownership: sessionOwnership,
|
||||
availableWorktreesByProject,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
cleanupSessions,
|
||||
});
|
||||
|
||||
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({
|
||||
isVSCode,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
normalizedProjects,
|
||||
ownership: sessionOwnership,
|
||||
});
|
||||
|
||||
useArchivedAutoFolders({
|
||||
normalizedProjects,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
isVSCode,
|
||||
ownership: sessionOwnership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
||||
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering plus project-row action affordances.
|
||||
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
|
||||
- `sessionOwnership.ts`: Resolves session directories once into shared project/worktree ownership and folder-scope indexes.
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -46,7 +47,7 @@
|
||||
- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior.
|
||||
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
|
||||
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
|
||||
- `hooks/useProjectSessionLists.ts`: Builds live and archived session lists for a given project (including worktrees + dedupe).
|
||||
- `hooks/useProjectSessionLists.ts`: Reads live and archived project buckets from the shared ownership index.
|
||||
- `hooks/useSessionFolderCleanup.ts`: Cleans stale folder session IDs by reconciling known sessions/archived scopes.
|
||||
- `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`.
|
||||
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import {
|
||||
collectKnownProjectDirectories,
|
||||
dedupeSessionsById,
|
||||
getArchivedScopeKey,
|
||||
isSessionRelatedToProject,
|
||||
normalizePath,
|
||||
resolveArchivedFolderName,
|
||||
} from '../utils';
|
||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
||||
|
||||
type ProjectForArchivedFolders = {
|
||||
id: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
@@ -22,83 +18,42 @@ type FolderEntry = {
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectForArchivedFolders[];
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
isVSCode: boolean;
|
||||
ownership: SessionOwnershipIndex;
|
||||
isSessionsLoading: boolean;
|
||||
hasAuthoritativeGlobalSessions: boolean;
|
||||
isWorktreeTopologyLoading: boolean;
|
||||
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
|
||||
foldersMap: Record<string, FolderEntry[]>;
|
||||
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
const getArchivedSessionsForProject = (
|
||||
project: ProjectForArchivedFolders,
|
||||
params: Pick<Args, 'sessions' | 'archivedSessions' | 'availableWorktreesByProject' | 'isVSCode'> & {
|
||||
knownProjectDirectories: Set<string>;
|
||||
},
|
||||
): Session[] => {
|
||||
const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const validDirectories = new Set<string>([
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
]);
|
||||
|
||||
const collect = (input: Session[]): Session[] => input.filter((session) =>
|
||||
isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories),
|
||||
);
|
||||
|
||||
const archived = collect(params.archivedSessions);
|
||||
const unassignedLive = params.sessions.filter((session) => {
|
||||
if (session.time?.archived) {
|
||||
return false;
|
||||
}
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, params.knownProjectDirectories);
|
||||
});
|
||||
|
||||
return dedupeSessionsById([...archived, ...unassignedLive]);
|
||||
};
|
||||
|
||||
export const useArchivedAutoFolders = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
isVSCode,
|
||||
ownership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
} = args;
|
||||
|
||||
const knownProjectDirectories = React.useMemo(
|
||||
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
|
||||
[normalizedProjects, availableWorktreesByProject, isVSCode],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSessionsLoading) {
|
||||
if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalizedProjects.forEach((project) => {
|
||||
if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) {
|
||||
return;
|
||||
}
|
||||
const scopeKey = getArchivedScopeKey(project.normalizedPath);
|
||||
const projectArchivedSessions = getArchivedSessionsForProject(project, {
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
isVSCode,
|
||||
knownProjectDirectories,
|
||||
});
|
||||
const projectArchivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
|
||||
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
|
||||
|
||||
const existingFolders = foldersMap[scopeKey] ?? [];
|
||||
@@ -122,12 +77,11 @@ export const useArchivedAutoFolders = (args: Args): void => {
|
||||
});
|
||||
}, [
|
||||
normalizedProjects,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
knownProjectDirectories,
|
||||
isVSCode,
|
||||
ownership,
|
||||
isSessionsLoading,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
|
||||
@@ -1,159 +1,27 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { collectKnownProjectDirectories, dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils';
|
||||
|
||||
type WorktreeMeta = { path: string };
|
||||
|
||||
type NormalizedProject = { id: string; normalizedPath: string };
|
||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
||||
|
||||
type Args = {
|
||||
isVSCode: boolean;
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
|
||||
/**
|
||||
* The set of normalized projects the sidebar will render. Used in
|
||||
* Layer 4.13 to precompute the allowed directory set so the per-row
|
||||
* `sessionsByDirectory` Map only contains buckets the sidebar will
|
||||
* actually consume. With 10 projects × 5 worktrees and 100 sessions
|
||||
* per directory this drops the Map from N entries to the small
|
||||
* subset the sidebar needs.
|
||||
*/
|
||||
normalizedProjects: NormalizedProject[];
|
||||
ownership: SessionOwnershipIndex;
|
||||
};
|
||||
|
||||
export const useProjectSessionLists = (args: Args) => {
|
||||
const {
|
||||
isVSCode,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
normalizedProjects,
|
||||
ownership,
|
||||
} = args;
|
||||
|
||||
// Precompute the set of directories the sidebar will ever ask about:
|
||||
// every project's normalized path plus the path of each registered
|
||||
// worktree. Walking this set is O(P + W) per Sidebar render and lets
|
||||
// us skip the bulk of `sessions` (whose directory is not associated
|
||||
// with a known project) when building `sessionsByDirectory`.
|
||||
const knownProjectDirectories = React.useMemo(
|
||||
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
|
||||
[normalizedProjects, availableWorktreesByProject, isVSCode],
|
||||
);
|
||||
|
||||
const sessionsByDirectory = React.useMemo(() => {
|
||||
const next = new Map<string, Session[]>();
|
||||
sessions.forEach((session) => {
|
||||
const directory = resolveGlobalSessionDirectory(session);
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
// Skip sessions whose directory doesn't belong to any known
|
||||
// project or worktree. Without this filter the Map grows with
|
||||
// every session the server has ever seen, even ones for
|
||||
// long-removed worktrees; the sidebar's downstream filters
|
||||
// would then drop them anyway.
|
||||
if (!knownProjectDirectories.has(directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const collection = next.get(directory) ?? [];
|
||||
collection.push(session);
|
||||
next.set(directory, collection);
|
||||
});
|
||||
return next;
|
||||
}, [sessions, knownProjectDirectories]);
|
||||
|
||||
const getSessionsForProject = React.useCallback(
|
||||
(project: { normalizedPath: string }) => {
|
||||
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const directories = [
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const collected: Session[] = [];
|
||||
|
||||
directories.forEach((directory) => {
|
||||
const sessionsForDirectory: Session[] = sessionsByDirectory.get(directory) ?? [];
|
||||
sessionsForDirectory.forEach((session) => {
|
||||
if (seen.has(session.id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(session.id);
|
||||
collected.push(session);
|
||||
});
|
||||
});
|
||||
|
||||
return collected;
|
||||
(projectId: string) => {
|
||||
return ownership.sessionsByProject.get(projectId) ?? [];
|
||||
},
|
||||
[availableWorktreesByProject, isVSCode, sessionsByDirectory],
|
||||
[ownership],
|
||||
);
|
||||
|
||||
const getArchivedSessionsForProject = React.useCallback(
|
||||
(project: { normalizedPath: string }) => {
|
||||
if (isVSCode) {
|
||||
const archived = archivedSessions.filter((session) => {
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
|
||||
if (sessionDirectory) {
|
||||
return sessionDirectory === project.normalizedPath;
|
||||
}
|
||||
|
||||
return projectWorktree === project.normalizedPath;
|
||||
});
|
||||
|
||||
const unassignedLive = sessions.filter((session) => {
|
||||
if (session.time?.archived) {
|
||||
return false;
|
||||
}
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
return projectWorktree === project.normalizedPath;
|
||||
});
|
||||
|
||||
return dedupeSessionsById([...archived, ...unassignedLive]);
|
||||
}
|
||||
|
||||
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const validDirectories = new Set<string>([
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
]);
|
||||
|
||||
const collect = (input: Session[]): Session[] => input.filter((session) =>
|
||||
isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories),
|
||||
);
|
||||
|
||||
const archived = collect(archivedSessions);
|
||||
const unassignedLive = sessions.filter((session) => {
|
||||
if (session.time?.archived) {
|
||||
return false;
|
||||
}
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
if (!projectWorktree) {
|
||||
return false;
|
||||
}
|
||||
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories);
|
||||
});
|
||||
|
||||
return dedupeSessionsById([...archived, ...unassignedLive]);
|
||||
(projectId: string) => {
|
||||
return ownership.archivedSessionsByProject.get(projectId) ?? [];
|
||||
},
|
||||
[archivedSessions, availableWorktreesByProject, isVSCode, knownProjectDirectories, sessions],
|
||||
[ownership],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,113 +1,80 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import {
|
||||
collectKnownProjectDirectories,
|
||||
dedupeSessionsById,
|
||||
getArchivedScopeKey,
|
||||
isSessionRelatedToProject,
|
||||
normalizePath,
|
||||
} from '../utils';
|
||||
import { getArchivedScopeKey, normalizePath } from '../utils';
|
||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
||||
|
||||
type WorktreeMeta = { path: string };
|
||||
|
||||
type NormalizedProject = {
|
||||
id: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type WorktreeMeta = { path: string };
|
||||
|
||||
type Args = {
|
||||
isSessionsLoading: boolean;
|
||||
hasLoadedGlobalSessions: boolean;
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
hasAuthoritativeGlobalSessions: boolean;
|
||||
isWorktreeTopologyLoading: boolean;
|
||||
normalizedProjects: NormalizedProject[];
|
||||
isVSCode: boolean;
|
||||
ownership: SessionOwnershipIndex;
|
||||
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
|
||||
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
|
||||
cleanupSessions: (scopeKey: string, validSessionIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
export const useSessionFolderCleanup = (args: Args): void => {
|
||||
const {
|
||||
isSessionsLoading,
|
||||
hasLoadedGlobalSessions,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
normalizedProjects,
|
||||
isVSCode,
|
||||
ownership,
|
||||
availableWorktreesByProject,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
cleanupSessions,
|
||||
} = args;
|
||||
|
||||
const knownProjectDirectories = React.useMemo(
|
||||
() => collectKnownProjectDirectories(normalizedProjects, availableWorktreesByProject, isVSCode),
|
||||
[normalizedProjects, availableWorktreesByProject, isVSCode],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSessionsLoading || !hasLoadedGlobalSessions) {
|
||||
if (isSessionsLoading || !hasAuthoritativeGlobalSessions || isWorktreeTopologyLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessions.length === 0 && archivedSessions.length === 0) {
|
||||
if (ownership.bySessionId.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idsByScope = new Map<string, Set<string>>();
|
||||
sessions.forEach((session) => {
|
||||
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
const existing = idsByScope.get(directory);
|
||||
if (existing) {
|
||||
existing.add(session.id);
|
||||
return;
|
||||
}
|
||||
idsByScope.set(directory, new Set([session.id]));
|
||||
ownership.sessionsByScope.forEach((sessionIds, scopeDirectory) => {
|
||||
idsByScope.set(scopeDirectory, new Set(sessionIds));
|
||||
});
|
||||
|
||||
normalizedProjects.forEach((project) => {
|
||||
if (unresolvedWorktreeProjectPaths.has(project.normalizedPath)) {
|
||||
return;
|
||||
}
|
||||
const scopeKey = getArchivedScopeKey(project.normalizedPath);
|
||||
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const validDirectories = new Set<string>([
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
]);
|
||||
|
||||
const archivedForProject = dedupeSessionsById([
|
||||
...archivedSessions,
|
||||
...sessions.filter((session) => {
|
||||
if (session.time?.archived) {
|
||||
return false;
|
||||
}
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories);
|
||||
}),
|
||||
]).filter((session) => isSessionRelatedToProject(session, project.normalizedPath, validDirectories, knownProjectDirectories));
|
||||
|
||||
idsByScope.set(scopeKey, new Set(archivedForProject.map((session) => session.id)));
|
||||
const archivedSessions = ownership.archivedSessionsByProject.get(project.id) ?? [];
|
||||
idsByScope.set(scopeKey, new Set(archivedSessions.map((session) => session.id)));
|
||||
if (!idsByScope.has(project.normalizedPath)) {
|
||||
idsByScope.set(project.normalizedPath, new Set());
|
||||
}
|
||||
for (const worktree of availableWorktreesByProject.get(project.normalizedPath) ?? []) {
|
||||
const worktreePath = normalizePath(worktree.path);
|
||||
if (worktreePath && !idsByScope.has(worktreePath)) {
|
||||
idsByScope.set(worktreePath, new Set());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const currentFoldersMap = useSessionFoldersStore.getState().foldersMap;
|
||||
const allScopeKeys = new Set([...Object.keys(currentFoldersMap), ...idsByScope.keys()]);
|
||||
allScopeKeys.forEach((scopeKey) => {
|
||||
cleanupSessions(scopeKey, idsByScope.get(scopeKey) ?? new Set<string>());
|
||||
idsByScope.forEach((sessionIds, scopeKey) => {
|
||||
cleanupSessions(scopeKey, sessionIds);
|
||||
});
|
||||
}, [
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
cleanupSessions,
|
||||
hasLoadedGlobalSessions,
|
||||
hasAuthoritativeGlobalSessions,
|
||||
isWorktreeTopologyLoading,
|
||||
isSessionsLoading,
|
||||
isVSCode,
|
||||
knownProjectDirectories,
|
||||
normalizedProjects,
|
||||
sessions,
|
||||
ownership,
|
||||
unresolvedWorktreeProjectPaths,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -23,8 +23,8 @@ type ProjectSection = {
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectItem[];
|
||||
getSessionsForProject: (project: { normalizedPath: string }) => Session[];
|
||||
getArchivedSessionsForProject: (project: { normalizedPath: string }) => Session[];
|
||||
getSessionsForProject: (projectId: string) => Session[];
|
||||
getArchivedSessionsForProject: (projectId: string) => Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
projectRootBranches: Map<string, string | null>;
|
||||
@@ -63,8 +63,8 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
const projectSections = React.useMemo<ProjectSection[]>(() => {
|
||||
return normalizedProjects.map((project) => {
|
||||
const projectSessions = dedupeSessionsById([
|
||||
...getSessionsForProject(project),
|
||||
...getArchivedSessionsForProject(project),
|
||||
...getSessionsForProject(project.id),
|
||||
...getArchivedSessionsForProject(project.id),
|
||||
]);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
const isRepo = projectRepoStatus.has(project.id)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { createSessionOwnershipIndex } from './sessionOwnership';
|
||||
|
||||
describe('createSessionOwnershipIndex', () => {
|
||||
test('assigns sessions to the deepest project and registered worktree', () => {
|
||||
const sessions = [
|
||||
{ id: 'nested', directory: '/projects/app/packages/admin/src' },
|
||||
{ id: 'external-worktree', directory: '/worktrees/app-feature/src' },
|
||||
{ id: 'worktree-fallback', project: { worktree: '/worktrees/app-feature/src' } },
|
||||
{ id: 'directory-wins', directory: '/projects/app/packages/admin', project: { worktree: '/projects/app' } },
|
||||
{ id: 'windows', directory: 'c:\\Projects\\App\\src' },
|
||||
{ id: 'unassigned', directory: '/elsewhere' },
|
||||
] as unknown as Session[];
|
||||
const projects = [
|
||||
{ id: 'app', normalizedPath: '/projects/app' },
|
||||
{ id: 'admin', normalizedPath: '/projects/app/packages/admin' },
|
||||
{ id: 'windows-app', normalizedPath: 'C:/Projects/App' },
|
||||
];
|
||||
const worktrees = new Map([
|
||||
['/projects/app', [{ path: '/worktrees/app-feature' }]],
|
||||
]);
|
||||
|
||||
const ownership = createSessionOwnershipIndex(sessions, projects, worktrees, false);
|
||||
|
||||
expect(ownership.bySessionId.get('nested')?.projectId).toBe('admin');
|
||||
expect(ownership.bySessionId.get('external-worktree')).toEqual({
|
||||
projectId: 'app',
|
||||
projectRoot: '/projects/app',
|
||||
scopeDirectory: '/worktrees/app-feature',
|
||||
kind: 'worktree',
|
||||
});
|
||||
expect(ownership.bySessionId.get('worktree-fallback')?.scopeDirectory).toBe('/worktrees/app-feature');
|
||||
expect(ownership.bySessionId.get('directory-wins')?.projectId).toBe('admin');
|
||||
expect(ownership.bySessionId.get('windows')?.projectId).toBe('windows-app');
|
||||
expect(ownership.bySessionId.has('unassigned')).toBe(false);
|
||||
expect(ownership.sessionsByProject.get('admin')?.map((session) => session.id)).toEqual([
|
||||
'nested',
|
||||
'directory-wins',
|
||||
]);
|
||||
expect(ownership.sessionsByScope.get('/worktrees/app-feature')).toEqual(new Set([
|
||||
'external-worktree',
|
||||
'worktree-fallback',
|
||||
]));
|
||||
});
|
||||
|
||||
test('gives an exact project precedence over a colliding worktree', () => {
|
||||
const ownership = createSessionOwnershipIndex(
|
||||
[{ id: 'nested', directory: '/projects/app/packages/admin/src' } as Session],
|
||||
[
|
||||
{ id: 'app', normalizedPath: '/projects/app' },
|
||||
{ id: 'admin', normalizedPath: '/projects/app/packages/admin' },
|
||||
],
|
||||
new Map([['/projects/app', [{ path: '/projects/app/packages/admin' }]]]),
|
||||
false,
|
||||
);
|
||||
|
||||
expect(ownership.bySessionId.get('nested')?.projectId).toBe('admin');
|
||||
expect(ownership.bySessionId.get('nested')?.kind).toBe('project');
|
||||
});
|
||||
|
||||
test('indexes archived sessions separately', () => {
|
||||
const ownership = createSessionOwnershipIndex(
|
||||
[],
|
||||
[{ id: 'app', normalizedPath: '/projects/app' }],
|
||||
new Map([['/projects/app', [{ path: '/worktrees/app-feature' }]]]),
|
||||
false,
|
||||
[
|
||||
{ id: 'archived-child', directory: '/worktrees/app-feature/src', time: { archived: 1 } },
|
||||
{ id: 'archived-fallback', project: { worktree: '/worktrees/app-feature' }, time: { archived: 1 } },
|
||||
] as unknown as Session[],
|
||||
);
|
||||
|
||||
expect(ownership.archivedSessionsByProject.get('app')?.map((session) => session.id)).toEqual([
|
||||
'archived-child',
|
||||
'archived-fallback',
|
||||
]);
|
||||
});
|
||||
|
||||
test('requires exact workspace directories in VS Code', () => {
|
||||
const ownership = createSessionOwnershipIndex(
|
||||
[
|
||||
{ id: 'workspace', directory: '/projects/app' },
|
||||
{ id: 'nested', directory: '/projects/app/packages/ui' },
|
||||
] as Session[],
|
||||
[{ id: 'app', normalizedPath: '/projects/app' }],
|
||||
new Map(),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(ownership.bySessionId.get('workspace')?.projectId).toBe('app');
|
||||
expect(ownership.bySessionId.has('nested')).toBe(false);
|
||||
});
|
||||
|
||||
test('supports a Windows drive root project', () => {
|
||||
const ownership = createSessionOwnershipIndex(
|
||||
[{ id: 'windows-root', directory: 'c:\\Users\\name\\project' } as Session],
|
||||
[{ id: 'drive', normalizedPath: 'C:/' }],
|
||||
new Map(),
|
||||
false,
|
||||
);
|
||||
|
||||
expect(ownership.bySessionId.get('windows-root')?.projectId).toBe('drive');
|
||||
});
|
||||
|
||||
test('resolves report-sized data once instead of once per project consumer', () => {
|
||||
const projects = Array.from({ length: 15 }, (_, index) => ({
|
||||
id: `project-${index}`,
|
||||
normalizedPath: `/projects/${index}`,
|
||||
}));
|
||||
const worktrees = new Map(projects.map((project, projectIndex) => [
|
||||
project.normalizedPath,
|
||||
Array.from({ length: projectIndex < 7 ? 5 : 4 }, (_, index) => ({
|
||||
path: `/worktrees/${projectIndex}/${index}`,
|
||||
})),
|
||||
]));
|
||||
const sessions = Array.from({ length: 14_561 }, (_, index) => ({
|
||||
id: `session-${index}`,
|
||||
directory: `/worktrees/${index % 15}/${index % 4}/session/${index}`,
|
||||
})) as unknown as Session[];
|
||||
|
||||
const ownership = createSessionOwnershipIndex(sessions, projects, worktrees, false);
|
||||
|
||||
expect(ownership.bySessionId.size).toBe(14_561);
|
||||
expect(ownership.directoryResolutions).toBeLessThan(14_561 * 2);
|
||||
expect([...ownership.sessionsByProject.values()].reduce((total, bucket) => total + bucket.length, 0)).toBe(14_561);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { normalizePath } from '@/lib/pathNormalization';
|
||||
|
||||
type Project = {
|
||||
id: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type Worktree = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type DirectoryOwner = {
|
||||
projectId: string;
|
||||
projectRoot: string;
|
||||
scopeDirectory: string;
|
||||
kind: 'project' | 'worktree';
|
||||
};
|
||||
|
||||
export type SessionOwnershipIndex = {
|
||||
bySessionId: Map<string, DirectoryOwner>;
|
||||
sessionsByProject: Map<string, Session[]>;
|
||||
archivedSessionsByProject: Map<string, Session[]>;
|
||||
sessionsByScope: Map<string, Set<string>>;
|
||||
directoryResolutions: number;
|
||||
};
|
||||
|
||||
const shouldReplaceOwner = (existing: DirectoryOwner | undefined, candidate: DirectoryOwner): boolean => {
|
||||
if (!existing) return true;
|
||||
if (candidate.kind !== existing.kind) {
|
||||
return candidate.kind === 'project';
|
||||
}
|
||||
if (candidate.projectRoot.length !== existing.projectRoot.length) {
|
||||
return candidate.projectRoot.length > existing.projectRoot.length;
|
||||
}
|
||||
return candidate.projectId.localeCompare(existing.projectId) < 0;
|
||||
};
|
||||
|
||||
const setOwner = (owners: Map<string, DirectoryOwner>, directory: string, candidate: DirectoryOwner): void => {
|
||||
if (shouldReplaceOwner(owners.get(directory), candidate)) {
|
||||
owners.set(directory, candidate);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveSessionDirectory = (session: Session): string | null => {
|
||||
const record = session as Session & {
|
||||
directory?: string | null;
|
||||
project?: { worktree?: string | null } | null;
|
||||
};
|
||||
return normalizePath(record.directory) ?? normalizePath(record.project?.worktree);
|
||||
};
|
||||
|
||||
const getParentDirectory = (directory: string): string | null => {
|
||||
if (directory === '/' || /^[A-Z]:$/.test(directory)) {
|
||||
return null;
|
||||
}
|
||||
const separator = directory.lastIndexOf('/');
|
||||
if (separator < 0) return null;
|
||||
if (separator === 0) return '/';
|
||||
if (separator === 2 && /^[A-Z]:\//.test(directory)) return directory.slice(0, 2);
|
||||
return directory.slice(0, separator);
|
||||
};
|
||||
|
||||
export const createSessionOwnershipIndex = (
|
||||
sessions: Session[],
|
||||
projects: Project[],
|
||||
availableWorktreesByProject: Map<string, Worktree[]>,
|
||||
isVSCode: boolean,
|
||||
archivedSessions: Session[] = [],
|
||||
): SessionOwnershipIndex => {
|
||||
const ownerByDirectory = new Map<string, DirectoryOwner>();
|
||||
const projectByRoot = new Map<string, Project>();
|
||||
|
||||
for (const project of projects) {
|
||||
const projectRoot = normalizePath(project.normalizedPath);
|
||||
if (!projectRoot) continue;
|
||||
const existingProject = projectByRoot.get(projectRoot);
|
||||
if (!existingProject || project.id.localeCompare(existingProject.id) < 0) {
|
||||
projectByRoot.set(projectRoot, project);
|
||||
}
|
||||
setOwner(ownerByDirectory, projectRoot, {
|
||||
projectId: project.id,
|
||||
projectRoot,
|
||||
scopeDirectory: projectRoot,
|
||||
kind: 'project',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isVSCode) {
|
||||
for (const [projectPath, worktrees] of availableWorktreesByProject) {
|
||||
const projectRoot = normalizePath(projectPath);
|
||||
const project = projectRoot ? projectByRoot.get(projectRoot) : undefined;
|
||||
if (!project || !projectRoot) continue;
|
||||
for (const worktree of worktrees) {
|
||||
const directory = normalizePath(worktree.path);
|
||||
if (!directory) continue;
|
||||
setOwner(ownerByDirectory, directory, {
|
||||
projectId: project.id,
|
||||
projectRoot,
|
||||
scopeDirectory: directory,
|
||||
kind: 'worktree',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedOwners = new Map<string, DirectoryOwner | null>();
|
||||
const bySessionId = new Map<string, DirectoryOwner>();
|
||||
const sessionsByProject = new Map<string, Session[]>();
|
||||
const archivedSessionsByProject = new Map<string, Session[]>();
|
||||
const sessionsByScope = new Map<string, Set<string>>();
|
||||
|
||||
const resolveOwner = (directory: string | null): DirectoryOwner | null => {
|
||||
if (!directory) return null;
|
||||
if (resolvedOwners.has(directory)) {
|
||||
return resolvedOwners.get(directory) ?? null;
|
||||
}
|
||||
|
||||
if (isVSCode) {
|
||||
const owner = ownerByDirectory.get(directory) ?? null;
|
||||
resolvedOwners.set(directory, owner);
|
||||
return owner;
|
||||
}
|
||||
|
||||
const visited: string[] = [];
|
||||
let current: string | null = directory;
|
||||
let owner: DirectoryOwner | null = null;
|
||||
while (current) {
|
||||
if (resolvedOwners.has(current)) {
|
||||
owner = resolvedOwners.get(current) ?? null;
|
||||
break;
|
||||
}
|
||||
visited.push(current);
|
||||
owner = ownerByDirectory.get(current) ?? null;
|
||||
if (owner) break;
|
||||
current = getParentDirectory(current);
|
||||
}
|
||||
for (const visitedDirectory of visited) {
|
||||
resolvedOwners.set(visitedDirectory, owner);
|
||||
}
|
||||
return owner;
|
||||
};
|
||||
|
||||
const bucket = (
|
||||
input: Session[],
|
||||
target: Map<string, Session[]>,
|
||||
scopeTarget?: Map<string, Set<string>>,
|
||||
): void => {
|
||||
for (const session of input) {
|
||||
const owner = resolveOwner(resolveSessionDirectory(session));
|
||||
if (!owner) continue;
|
||||
bySessionId.set(session.id, owner);
|
||||
const projectSessions = target.get(owner.projectId);
|
||||
if (projectSessions) {
|
||||
projectSessions.push(session);
|
||||
} else {
|
||||
target.set(owner.projectId, [session]);
|
||||
}
|
||||
if (!scopeTarget) continue;
|
||||
const scopeSessions = scopeTarget.get(owner.scopeDirectory);
|
||||
if (scopeSessions) {
|
||||
scopeSessions.add(session.id);
|
||||
} else {
|
||||
scopeTarget.set(owner.scopeDirectory, new Set([session.id]));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
bucket(sessions, sessionsByProject, sessionsByScope);
|
||||
bucket(archivedSessions, archivedSessionsByProject);
|
||||
|
||||
return {
|
||||
bySessionId,
|
||||
sessionsByProject,
|
||||
archivedSessionsByProject,
|
||||
sessionsByScope,
|
||||
directoryResolutions: resolvedOwners.size,
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { isPathWithinProject, isSessionRelatedToProject } from './utils';
|
||||
import { isPathWithinProject } from './utils';
|
||||
|
||||
describe('isPathWithinProject', () => {
|
||||
test('matches child directories for root projects', () => {
|
||||
@@ -28,86 +26,3 @@ describe('isPathWithinProject', () => {
|
||||
expect(isPathWithinProject('/workspace/app/sub/dir', '/workspace/app')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSessionRelatedToProject', () => {
|
||||
test('prefers the most specific project root for archived session directories', () => {
|
||||
const session = {
|
||||
id: 'ses_parent_child',
|
||||
directory: '/home/user/proj/foo/src',
|
||||
} as unknown as Session;
|
||||
|
||||
const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']);
|
||||
|
||||
expect(
|
||||
isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSessionRelatedToProject(
|
||||
session,
|
||||
'/home/user/proj/foo',
|
||||
new Set(['/home/user/proj/foo']),
|
||||
knownProjectDirectories,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('prefers the most specific project worktree when session directory is missing', () => {
|
||||
const session = {
|
||||
id: 'ses_project_worktree',
|
||||
project: {
|
||||
worktree: '/home/user/proj/foo',
|
||||
},
|
||||
} as unknown as Session;
|
||||
|
||||
const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']);
|
||||
|
||||
expect(
|
||||
isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSessionRelatedToProject(
|
||||
session,
|
||||
'/home/user/proj/foo',
|
||||
new Set(['/home/user/proj/foo']),
|
||||
knownProjectDirectories,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('prefers explicit session directory over broader project worktree metadata', () => {
|
||||
const session = {
|
||||
id: 'ses_directory_beats_worktree',
|
||||
directory: '/home/user/proj/foo/src',
|
||||
project: {
|
||||
worktree: '/home/user',
|
||||
},
|
||||
} as unknown as Session;
|
||||
|
||||
const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']);
|
||||
|
||||
expect(
|
||||
isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSessionRelatedToProject(
|
||||
session,
|
||||
'/home/user/proj/foo',
|
||||
new Set(['/home/user/proj/foo']),
|
||||
knownProjectDirectories,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps descendant sessions on the broad project when no child project matches', () => {
|
||||
const session = {
|
||||
id: 'ses_home_misc',
|
||||
directory: '/home/user/misc/sandbox',
|
||||
} as unknown as Session;
|
||||
|
||||
const knownProjectDirectories = new Set(['/home/user', '/home/user/proj/foo']);
|
||||
|
||||
expect(
|
||||
isSessionRelatedToProject(session, '/home/user', new Set(['/home/user']), knownProjectDirectories),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,67 +83,16 @@ export const formatSessionCompactDateLabel = (updatedMs: number): string => {
|
||||
export const isPathWithinProject = (directory?: string | null, projectPath?: string | null): boolean => {
|
||||
const normalizedDirectory = normalizePath(directory);
|
||||
const normalizedProjectPath = normalizePath(projectPath);
|
||||
return isNormalizedPathWithinProject(normalizedDirectory, normalizedProjectPath);
|
||||
};
|
||||
|
||||
const isNormalizedPathWithinProject = (normalizedDirectory: string | null, normalizedProjectPath: string | null): boolean => {
|
||||
if (!normalizedDirectory || !normalizedProjectPath) return false;
|
||||
if (normalizedDirectory === normalizedProjectPath) return true;
|
||||
if (normalizedProjectPath === '/') return normalizedDirectory.startsWith('/');
|
||||
return normalizedDirectory.startsWith(`${normalizedProjectPath}/`);
|
||||
};
|
||||
|
||||
type NormalizedProjectPath = { normalizedPath: string };
|
||||
type WorktreePath = { path: string };
|
||||
|
||||
export const collectKnownProjectDirectories = (
|
||||
normalizedProjects: NormalizedProjectPath[],
|
||||
availableWorktreesByProject: Map<string, WorktreePath[]>,
|
||||
isVSCode: boolean,
|
||||
): Set<string> => {
|
||||
const knownDirectories = new Set<string>();
|
||||
|
||||
normalizedProjects.forEach((project) => {
|
||||
if (project.normalizedPath) {
|
||||
knownDirectories.add(project.normalizedPath);
|
||||
}
|
||||
});
|
||||
|
||||
if (isVSCode) {
|
||||
return knownDirectories;
|
||||
}
|
||||
|
||||
for (const worktrees of availableWorktreesByProject.values()) {
|
||||
for (const worktree of worktrees) {
|
||||
const normalized = normalizePath(worktree.path);
|
||||
if (normalized) {
|
||||
knownDirectories.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return knownDirectories;
|
||||
};
|
||||
|
||||
const findBestProjectDirectoryMatch = (
|
||||
value: string | null,
|
||||
knownDirectories?: Iterable<string>,
|
||||
): string | null => {
|
||||
if (!value || !knownDirectories) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bestMatch: string | null = null;
|
||||
for (const candidate of knownDirectories) {
|
||||
const normalizedCandidate = normalizePath(candidate);
|
||||
if (!normalizedCandidate || !isPathWithinProject(value, normalizedCandidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bestMatch || normalizedCandidate.length > bestMatch.length) {
|
||||
bestMatch = normalizedCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
};
|
||||
|
||||
export const normalizeForBranchComparison = (value: string): string => {
|
||||
return value
|
||||
.toLowerCase()
|
||||
@@ -223,33 +172,6 @@ export const resolveArchivedFolderName = (session: Session, projectRoot: string
|
||||
return segments[segments.length - 1] ?? 'unassigned';
|
||||
};
|
||||
|
||||
export const isSessionRelatedToProject = (
|
||||
session: Session,
|
||||
projectRoot: string,
|
||||
validDirectories?: Set<string>,
|
||||
knownDirectories?: Iterable<string>,
|
||||
): boolean => {
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
const resolvedDirectory = sessionDirectory ?? projectWorktree;
|
||||
|
||||
if (resolvedDirectory && validDirectories?.has(resolvedDirectory)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!resolvedDirectory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bestMatch = findBestProjectDirectoryMatch(resolvedDirectory, knownDirectories);
|
||||
if (bestMatch) {
|
||||
return validDirectories ? validDirectories.has(bestMatch) : bestMatch === projectRoot;
|
||||
}
|
||||
|
||||
return resolvedDirectory === projectRoot || resolvedDirectory.startsWith(`${projectRoot}/`);
|
||||
};
|
||||
|
||||
|
||||
export const formatProjectLabel = (label: string): string => {
|
||||
return label
|
||||
.replace(/[-_]/g, ' ')
|
||||
|
||||
Reference in New Issue
Block a user