* chore: remove dead/unreferenced files across ui, vscode Remove 59 unused source files (components, hooks, lib utils, stores, barrels, and orphaned vscode github modules) that are not imported by any entry-reachable code. Also drop a stale test mock for the removed execCommands module. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove unused exported symbols (types, functions, consts, hooks) Remove exported symbols whose identifier is referenced nowhere in the repository (verified via repo-wide search), across ui types/contracts, lib utilities, sync layer, stores, and components. Also drop the few imports/private helpers orphaned by these removals. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * refactor: remove more unused exports (desktop, shortcuts, worktree, vscode) Continue removing repo-wide unreferenced exported functions, consts and types across lib/desktop, shortcuts, worktreeSessionCreator, sync, and vscode gitService, with cascading orphaned helpers/imports cleaned up. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> * chore: add dead-code cleanup tooling * refactor: checkpoint dead-code cleanup * refactor: remove dead-code suppressions --------- Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
import { create } from 'zustand';
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
|
import { listProjectWorktrees, removeProjectWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
|
import { useDirectoryStore } from './useDirectoryStore';
|
|
import { useProjectsStore } from './useProjectsStore';
|
|
import { deleteSessionInDirectory } from '@/sync/session-actions';
|
|
import { retry } from '@/sync/retry';
|
|
import type { WorktreeMetadata } from '@/types/worktree';
|
|
import type { Session } from '@opencode-ai/sdk/v2';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utilities
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const normalize = (value: string): string => {
|
|
if (!value) return '';
|
|
const replaced = value.replace(/\\/g, '/');
|
|
if (replaced === '/') return '/';
|
|
return replaced.replace(/\/+$/, '');
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AgentGroupSession {
|
|
id: string;
|
|
path: string;
|
|
providerId: string;
|
|
modelId: string;
|
|
instanceNumber: number;
|
|
branch: string;
|
|
displayLabel: string;
|
|
worktreeMetadata?: WorktreeMetadata;
|
|
}
|
|
|
|
export interface AgentGroup {
|
|
name: string;
|
|
sessions: AgentGroupSession[];
|
|
lastActive: number;
|
|
sessionCount: number;
|
|
}
|
|
|
|
interface DeleteAgentGroupResult {
|
|
failedIds: string[];
|
|
failedWorktreePaths: string[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// parseSessionTitle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function parseSessionTitle(title: string | undefined): {
|
|
groupSlug: string;
|
|
provider: string;
|
|
model: string;
|
|
index: number;
|
|
} | null {
|
|
if (!title) return null;
|
|
const parts = title.split('/');
|
|
if (parts.length < 3) return null;
|
|
|
|
const groupSlug = parts[0];
|
|
if (!groupSlug || groupSlug.includes(' ')) return null;
|
|
|
|
const provider = parts[1];
|
|
if (!provider) return null;
|
|
|
|
const lastPart = parts[parts.length - 1];
|
|
const lastPartNum = parseInt(lastPart, 10);
|
|
const hasIndex = parts.length >= 4 && !isNaN(lastPartNum) && String(lastPartNum) === lastPart;
|
|
|
|
const modelParts = hasIndex ? parts.slice(2, -1) : parts.slice(2);
|
|
if (modelParts.length === 0) return null;
|
|
|
|
return { groupSlug, provider, model: modelParts.join('/'), index: hasIndex ? lastPartNum : 1 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// resolveProjectRef
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function resolveProjectRef(): { id: string; path: string } | null {
|
|
const currentDirectory = useDirectoryStore.getState().currentDirectory;
|
|
const projectsState = useProjectsStore.getState();
|
|
const activeProjectId = projectsState.activeProjectId;
|
|
const activeProjectPath = activeProjectId
|
|
? projectsState.projects.find((p) => p.id === activeProjectId)?.path
|
|
: undefined;
|
|
|
|
const raw = (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0)
|
|
? activeProjectPath
|
|
: currentDirectory;
|
|
|
|
if (!raw) return null;
|
|
const path = normalize(raw);
|
|
if (!path) return null;
|
|
|
|
const entry = projectsState.projects.find((p) => normalize(p.path) === path);
|
|
return { id: entry?.id ?? `path:${path}`, path };
|
|
}
|
|
|
|
function resolveProjectRefForWorktree(session: AgentGroupSession): ProjectRef | null {
|
|
const projectsState = useProjectsStore.getState();
|
|
const projectPath = normalize(session.worktreeMetadata?.projectDirectory ?? '');
|
|
if (projectPath) {
|
|
const project = projectsState.projects.find((entry) => normalize(entry.path) === projectPath);
|
|
return { id: project?.id ?? `path:${projectPath}`, path: projectPath };
|
|
}
|
|
return resolveProjectRef();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// buildGroups — turns raw sessions + worktree metadata into AgentGroup[]
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildGroups(
|
|
sessions: Session[],
|
|
metaByPath: Map<string, WorktreeMetadata>,
|
|
): AgentGroup[] {
|
|
const map = new Map<string, AgentGroupSession[]>();
|
|
|
|
for (const session of sessions) {
|
|
const parsed = parseSessionTitle(session.title);
|
|
if (!parsed) continue;
|
|
|
|
const sessionPath = normalize(session.directory ?? '');
|
|
const meta = metaByPath.get(sessionPath);
|
|
|
|
const entry: AgentGroupSession = {
|
|
id: session.id,
|
|
path: sessionPath,
|
|
providerId: parsed.provider,
|
|
modelId: parsed.model,
|
|
instanceNumber: parsed.index,
|
|
branch: meta?.branch ?? '',
|
|
displayLabel: `${parsed.provider}/${parsed.model}`,
|
|
worktreeMetadata: meta,
|
|
};
|
|
|
|
const existing = map.get(parsed.groupSlug);
|
|
if (existing) existing.push(entry);
|
|
else map.set(parsed.groupSlug, [entry]);
|
|
}
|
|
|
|
const groups: AgentGroup[] = [];
|
|
for (const [name, groupSessions] of map) {
|
|
const lastActive = groupSessions.reduce((max, gs) => {
|
|
const raw = sessions.find((s) => s.id === gs.id);
|
|
const t = (raw as { time?: { updated?: number | null } } | undefined)?.time?.updated ?? 0;
|
|
return Math.max(max, typeof t === 'number' ? t : 0);
|
|
}, 0);
|
|
|
|
groupSessions.sort((a, b) => {
|
|
const p = a.providerId.localeCompare(b.providerId);
|
|
if (p !== 0) return p;
|
|
const m = a.modelId.localeCompare(b.modelId);
|
|
if (m !== 0) return m;
|
|
return a.instanceNumber - b.instanceNumber;
|
|
});
|
|
|
|
groups.push({ name, sessions: groupSessions, lastActive: lastActive || Date.now(), sessionCount: groupSessions.length });
|
|
}
|
|
|
|
groups.sort((a, b) => a.name.localeCompare(b.name));
|
|
return groups;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Store
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface AgentGroupsState {
|
|
groups: AgentGroup[];
|
|
selectedGroupName: string | null;
|
|
selectedSessionId: string | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
interface AgentGroupsActions {
|
|
/** List worktrees, fetch sessions per worktree, build groups. */
|
|
loadGroups: () => Promise<void>;
|
|
selectGroup: (groupName: string | null) => void;
|
|
selectSession: (sessionId: string | null) => void;
|
|
deleteGroupSessions: (sessions: AgentGroupSession[], options?: { removeWorktrees?: boolean }) => Promise<DeleteAgentGroupResult>;
|
|
clearError: () => void;
|
|
}
|
|
|
|
type Store = AgentGroupsState & AgentGroupsActions;
|
|
|
|
export const useAgentGroupsStore = create<Store>()(
|
|
(set, get) => ({
|
|
groups: [],
|
|
selectedGroupName: null,
|
|
selectedSessionId: null,
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
loadGroups: async () => {
|
|
const projectRef = resolveProjectRef();
|
|
if (!projectRef) {
|
|
set({ groups: [], isLoading: false, error: 'No project directory' });
|
|
return;
|
|
}
|
|
|
|
set({ isLoading: true, error: null });
|
|
|
|
try {
|
|
// 1. List worktrees (already cached 30s by worktreeManager)
|
|
const worktrees = await listProjectWorktrees(projectRef);
|
|
const metaByPath = new Map<string, WorktreeMetadata>();
|
|
const dirs: string[] = [];
|
|
for (const meta of worktrees) {
|
|
if (meta?.path) {
|
|
const key = normalize(meta.path);
|
|
dirs.push(key);
|
|
metaByPath.set(key, meta);
|
|
}
|
|
}
|
|
|
|
if (dirs.length === 0) {
|
|
set({ groups: [], isLoading: false });
|
|
return;
|
|
}
|
|
|
|
// 2. Fetch sessions for each worktree directory (parallel, max 5)
|
|
const api = opencodeClient.getApiClient();
|
|
const allSessions: Session[] = [];
|
|
const failedDirectories = new Set<string>();
|
|
|
|
const fetchDir = async (dir: string) => {
|
|
try {
|
|
const res = await retry(async () => {
|
|
const result = await api.session.list({ directory: dir });
|
|
if ((result as { error?: unknown }).error) {
|
|
throw new Error(`session.list failed for ${dir}: ${String((result as { error?: unknown }).error)}`);
|
|
}
|
|
return result;
|
|
});
|
|
const list = Array.isArray(res.data) ? res.data : [];
|
|
for (const s of list) if (s?.id) allSessions.push(s);
|
|
} catch {
|
|
failedDirectories.add(dir);
|
|
}
|
|
};
|
|
|
|
// Simple concurrency limiter
|
|
let idx = 0;
|
|
const worker = async () => {
|
|
while (idx < dirs.length) {
|
|
const i = idx++;
|
|
await fetchDir(dirs[i]);
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.min(5, dirs.length) }, () => worker()));
|
|
|
|
// 3. Build groups
|
|
const groups = buildGroups(allSessions, metaByPath);
|
|
set({
|
|
groups,
|
|
isLoading: false,
|
|
error: failedDirectories.size > 0 ? `Failed to load sessions for ${failedDirectories.size} worktree${failedDirectories.size === 1 ? '' : 's'}` : null,
|
|
});
|
|
} catch (err) {
|
|
set({
|
|
groups: get().groups, // preserve on error
|
|
isLoading: false,
|
|
error: err instanceof Error ? err.message : 'Failed to load groups',
|
|
});
|
|
}
|
|
},
|
|
|
|
selectGroup: (groupName) => {
|
|
if (!groupName) {
|
|
set({ selectedGroupName: null, selectedSessionId: null });
|
|
return;
|
|
}
|
|
const group = get().groups.find((g) => g.name === groupName);
|
|
set({
|
|
selectedGroupName: groupName,
|
|
selectedSessionId: group?.sessions[0]?.id ?? null,
|
|
});
|
|
},
|
|
|
|
selectSession: (sessionId) => set({ selectedSessionId: sessionId }),
|
|
|
|
deleteGroupSessions: async (sessions, options) => {
|
|
const failedIds: string[] = [];
|
|
const failedWorktreePaths: string[] = [];
|
|
const removeWorktrees = options?.removeWorktrees === true;
|
|
const deletedIds = new Set<string>();
|
|
|
|
for (const s of sessions) {
|
|
if (!s.path) continue;
|
|
const ok = await deleteSessionInDirectory(s.id, s.path);
|
|
if (!ok) failedIds.push(s.id);
|
|
else deletedIds.add(s.id);
|
|
}
|
|
|
|
if (removeWorktrees) {
|
|
const worktreesByPath = new Map<string, AgentGroupSession[]>();
|
|
for (const session of sessions) {
|
|
const path = normalize(session.path);
|
|
if (!path) continue;
|
|
const existing = worktreesByPath.get(path);
|
|
if (existing) existing.push(session);
|
|
else worktreesByPath.set(path, [session]);
|
|
}
|
|
|
|
for (const [path, pathSessions] of worktreesByPath) {
|
|
if (pathSessions.some((session) => failedIds.includes(session.id))) {
|
|
failedWorktreePaths.push(path);
|
|
continue;
|
|
}
|
|
|
|
const source = pathSessions.find((session) => session.worktreeMetadata)?.worktreeMetadata ?? pathSessions[0]?.worktreeMetadata;
|
|
const projectRef = pathSessions.map(resolveProjectRefForWorktree).find((value): value is ProjectRef => value !== null) ?? null;
|
|
if (!source || !projectRef) {
|
|
failedWorktreePaths.push(path);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await removeProjectWorktree(projectRef, source, { deleteLocalBranch: true });
|
|
const directoryStore = useDirectoryStore.getState();
|
|
if (normalize(directoryStore.currentDirectory) === path) {
|
|
directoryStore.setDirectory(projectRef.path, { showOverlay: false });
|
|
}
|
|
} catch {
|
|
failedWorktreePaths.push(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear selection if needed
|
|
const { selectedSessionId, selectedGroupName } = get();
|
|
if (selectedSessionId && deletedIds.has(selectedSessionId)) {
|
|
set({ selectedSessionId: null });
|
|
}
|
|
if (selectedGroupName) {
|
|
const group = get().groups.find((g) => g.name === selectedGroupName);
|
|
if (group && group.sessions.every((s) => deletedIds.has(s.id))) {
|
|
set({ selectedGroupName: null, selectedSessionId: null });
|
|
}
|
|
}
|
|
|
|
// Refresh groups after delete
|
|
void get().loadGroups();
|
|
|
|
return { failedIds, failedWorktreePaths };
|
|
},
|
|
|
|
clearError: () => set({ error: null }),
|
|
}),
|
|
);
|