chore: remove dead code (59 unused files + ~125 unused exports) (#1835)
* 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>
This commit is contained in:
committed by
GitHub
co-authored by
Serhii Dziupin
Bohdan Triapitsyn
parent
4a37b9a005
commit
00821700de
@@ -1,575 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi';
|
||||
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface BranchPickerProject {
|
||||
id: string;
|
||||
path: string;
|
||||
normalizedPath: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface BranchPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
project: BranchPickerProject | null;
|
||||
}
|
||||
|
||||
const displayProjectName = (project: BranchPickerProject): string =>
|
||||
project.label || project.normalizedPath.split('/').pop() || project.normalizedPath;
|
||||
|
||||
const normalizeBranchName = (value: string | null | undefined): string => {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/^refs\/heads\//, '')
|
||||
.replace(/^heads\//, '')
|
||||
.replace(/^remotes\//, '');
|
||||
};
|
||||
|
||||
const normalizePath = (value: string | null | undefined): string => {
|
||||
const raw = String(value || '').trim().replace(/\\/g, '/');
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (raw === '/') {
|
||||
return '/';
|
||||
}
|
||||
return raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
|
||||
};
|
||||
|
||||
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const sessions = useSessions();
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [branches, setBranches] = React.useState<GitBranch | null>(null);
|
||||
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
|
||||
const [rootBranchName, setRootBranchName] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [creatingWorktreeBranch, setCreatingWorktreeBranch] = React.useState<string | null>(null);
|
||||
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
|
||||
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
|
||||
const [editingBranch, setEditingBranch] = React.useState<string | null>(null);
|
||||
const [editValue, setEditValue] = React.useState('');
|
||||
const [renamingBranchKey, setRenamingBranchKey] = React.useState<string | null>(null);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!project) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [b, w, rootBranch] = await Promise.all([
|
||||
getGitBranches(project.path),
|
||||
git.worktree.list(project.path),
|
||||
getRootBranch(project.path).catch(() => null),
|
||||
]);
|
||||
setBranches(b);
|
||||
setWorktrees(w);
|
||||
setRootBranchName(rootBranch);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('branchPickerDialog.error.failedToLoad'));
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setRootBranchName(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [project, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery('');
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
setCreatingWorktreeBranch(null);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [open, refresh]);
|
||||
|
||||
const filterBranches = (list: string[], query: string): string[] => {
|
||||
if (!query.trim()) return list;
|
||||
const lower = query.toLowerCase();
|
||||
return list.filter((b) => b.toLowerCase().includes(lower));
|
||||
};
|
||||
|
||||
const beginRename = React.useCallback((branchName: string) => {
|
||||
setEditingBranch(branchName);
|
||||
setEditValue(branchName);
|
||||
}, []);
|
||||
|
||||
const cancelRename = React.useCallback(() => {
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
}, []);
|
||||
|
||||
const cancelDelete = React.useCallback(() => {
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
}, []);
|
||||
|
||||
const commitRename = React.useCallback(async (oldName: string) => {
|
||||
if (!project) return;
|
||||
const newName = editValue.trim();
|
||||
if (!newName || newName === oldName) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
setRenamingBranchKey(oldName);
|
||||
try {
|
||||
const result = await renameBranch(project.path, oldName, newName);
|
||||
if (!result?.success) {
|
||||
throw new Error(t('branchPickerDialog.error.renameRejected'));
|
||||
}
|
||||
await refresh();
|
||||
cancelRename();
|
||||
toast.success(t('branchPickerDialog.toast.branchRenamed'), { description: `${oldName} -> ${newName}` });
|
||||
} catch (err) {
|
||||
toast.error(t('branchPickerDialog.toast.failedToRenameBranch'), {
|
||||
description: err instanceof Error ? err.message : t('branchPickerDialog.error.renameFailed'),
|
||||
});
|
||||
setRenamingBranchKey(null);
|
||||
}
|
||||
}, [project, editValue, refresh, cancelRename, t]);
|
||||
|
||||
const handleDeleteBranch = React.useCallback(async (branchName: string) => {
|
||||
if (!project) return;
|
||||
setDeletingBranch(branchName);
|
||||
try {
|
||||
const force = forceDeleteBranch === branchName;
|
||||
const result = await deleteGitBranch(project.path, { branch: branchName, force });
|
||||
if (!result?.success) {
|
||||
throw new Error(t('branchPickerDialog.error.deleteRejected'));
|
||||
}
|
||||
await refresh();
|
||||
toast.success(t('branchPickerDialog.toast.branchDeleted'), { description: branchName });
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t('branchPickerDialog.error.deleteFailed');
|
||||
// If branch isn't merged, prompt for force delete on next confirm.
|
||||
if (/not fully merged/i.test(message) && forceDeleteBranch !== branchName) {
|
||||
setForceDeleteBranch(branchName);
|
||||
toast.error(t('branchPickerDialog.toast.branchNotMerged'), {
|
||||
description: t('branchPickerDialog.toast.confirmAgainToForceDelete'),
|
||||
});
|
||||
} else {
|
||||
toast.error(t('branchPickerDialog.toast.failedToDeleteBranch'), { description: message });
|
||||
}
|
||||
} finally {
|
||||
setDeletingBranch(null);
|
||||
}
|
||||
}, [project, refresh, forceDeleteBranch, t]);
|
||||
|
||||
const handleCreateWorktreeForBranch = React.useCallback(async (branchName: string) => {
|
||||
if (!project) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreatingWorktreeBranch(branchName);
|
||||
try {
|
||||
const setupCommands = await getWorktreeSetupCommands({
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
});
|
||||
await createWorktreeWithDefaults(
|
||||
{
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
},
|
||||
{
|
||||
preferredName: branchName,
|
||||
mode: 'existing',
|
||||
existingBranch: branchName,
|
||||
branchName,
|
||||
worktreeName: branchName,
|
||||
setupCommands,
|
||||
}
|
||||
);
|
||||
await refresh();
|
||||
toast.success(t('branchPickerDialog.toast.worktreeCreated'), { description: branchName });
|
||||
} catch (err) {
|
||||
toast.error(t('branchPickerDialog.toast.failedToCreateWorktree'), {
|
||||
description: err instanceof Error ? err.message : t('branchPickerDialog.error.createWorktreeFailed'),
|
||||
});
|
||||
} finally {
|
||||
setCreatingWorktreeBranch(null);
|
||||
}
|
||||
}, [project, refresh, t]);
|
||||
|
||||
const handleRemoveWorktree = React.useCallback((worktree: GitWorktreeInfo | null) => {
|
||||
if (!project || !worktree) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedWorktreePath = normalizePath(worktree.path);
|
||||
const directSessions = sessions.filter((session) => {
|
||||
const sessionPath = normalizePath(session.directory ?? null);
|
||||
return Boolean(sessionPath) && sessionPath === normalizedWorktreePath;
|
||||
});
|
||||
const directSessionIds = new Set(directSessions.map((session) => session.id));
|
||||
|
||||
const findSubsessions = (parentIds: Set<string>): typeof sessions => {
|
||||
const subsessions = sessions.filter((session) => {
|
||||
const parentID = (session as { parentID?: string | null }).parentID;
|
||||
if (!parentID) {
|
||||
return false;
|
||||
}
|
||||
return parentIds.has(parentID);
|
||||
});
|
||||
if (subsessions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const subsessionIds = new Set(subsessions.map((session) => session.id));
|
||||
return [...subsessions, ...findSubsessions(subsessionIds)];
|
||||
};
|
||||
|
||||
const allSubsessions = findSubsessions(directSessionIds);
|
||||
const seenIds = new Set<string>();
|
||||
const allSessions = [...directSessions, ...allSubsessions].filter((session) => {
|
||||
if (seenIds.has(session.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(session.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const normalizedBranch = normalizeBranchName(worktree.branch);
|
||||
const worktreeMetadata: WorktreeMetadata = {
|
||||
source: 'sdk',
|
||||
name: worktree.name,
|
||||
path: worktree.path,
|
||||
projectDirectory: project.path,
|
||||
branch: normalizedBranch,
|
||||
label: normalizedBranch || worktree.name,
|
||||
};
|
||||
|
||||
sessionEvents.requestDelete({
|
||||
sessions: allSessions,
|
||||
mode: 'worktree',
|
||||
worktree: worktreeMetadata,
|
||||
});
|
||||
}, [project, sessions]);
|
||||
|
||||
const worktreeByBranch = new Map<string, GitWorktreeInfo>();
|
||||
for (const worktree of worktrees) {
|
||||
const branchName = normalizeBranchName(worktree.branch);
|
||||
if (branchName && !worktreeByBranch.has(branchName)) {
|
||||
worktreeByBranch.set(branchName, worktree);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedRootBranch = normalizeBranchName(rootBranchName);
|
||||
const allBranches = branches?.all || [];
|
||||
const filteredBranches = filterBranches(allBranches, searchQuery);
|
||||
const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/'));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden gap-3">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Icon name="git-branch" className="h-5 w-5" />
|
||||
{t('branchPickerDialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{project ? t('branchPickerDialog.description.localBranchesForProject', { project: displayProjectName(project) }) : t('branchPickerDialog.description.selectProject')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative flex-shrink-0">
|
||||
<Icon name="search" className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('branchPickerDialog.search.placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{!project ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('branchPickerDialog.state.noProjectSelected')}</div>
|
||||
) : loading ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">{t('branchPickerDialog.state.loadingBranches')}</div>
|
||||
) : error ? (
|
||||
<div className="px-2 py-2 text-destructive text-sm">{error}</div>
|
||||
) : localBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">
|
||||
{searchQuery ? t('branchPickerDialog.state.noMatchingBranches') : t('branchPickerDialog.state.noBranchesFound')}
|
||||
</div>
|
||||
) : (
|
||||
localBranches.map((branchName) => {
|
||||
const details = branches?.branches[branchName];
|
||||
const normalizedBranchName = normalizeBranchName(branchName);
|
||||
const isCurrent = Boolean(details?.current);
|
||||
const isDeleting = deletingBranch === branchName;
|
||||
const isRenaming = renamingBranchKey === branchName;
|
||||
const attachedWorktree = worktreeByBranch.get(normalizedBranchName) ?? null;
|
||||
const hasAttachedWorktree = Boolean(attachedWorktree);
|
||||
const isProjectRootBranch = Boolean(
|
||||
normalizedBranchName &&
|
||||
normalizedRootBranch &&
|
||||
normalizedBranchName === normalizedRootBranch
|
||||
);
|
||||
const isEditing = editingBranch === branchName;
|
||||
const isConfirming = confirmingDelete === branchName;
|
||||
const isForceDelete = forceDeleteBranch === branchName;
|
||||
const isCreatingWorktree = creatingWorktreeBranch === branchName;
|
||||
|
||||
const disableCreateWorktree = Boolean(
|
||||
hasAttachedWorktree || isCreatingWorktree || isDeleting || isRenaming || isEditing
|
||||
);
|
||||
const disableDelete = Boolean(
|
||||
isCurrent || isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
|
||||
);
|
||||
const disableRename = Boolean(
|
||||
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
|
||||
);
|
||||
const disableWorktreeDelete = Boolean(
|
||||
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch || !attachedWorktree
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={branchName}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 hover:bg-interactive-hover/30 rounded-md overflow-hidden"
|
||||
>
|
||||
<Icon name="git-branch" className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isEditing ? (
|
||||
<form
|
||||
className="flex w-full items-center min-w-0"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void commitRename(branchName);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={editValue}
|
||||
onChange={(event) => setEditValue(event.target.value)}
|
||||
className="flex-1 min-w-0 h-5 bg-transparent text-sm leading-none outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
placeholder={t('branchPickerDialog.search.renameBranchPlaceholder')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void commitRename(branchName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<span className={cn('text-sm truncate', isCurrent && 'font-medium text-primary')}>
|
||||
{branchName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
{t('branchPickerDialog.badge.head')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{hasAttachedWorktree && !isEditing && (
|
||||
<span className="text-xs bg-muted/40 text-muted-foreground px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
{t('branchPickerDialog.badge.worktree')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{details?.commit ? (
|
||||
<span className="font-mono">{details.commit.slice(0, 7)}</span>
|
||||
) : null}
|
||||
{typeof details?.ahead === 'number' && details.ahead > 0 ? (
|
||||
<span className="text-[color:var(--status-success)]">↑{details.ahead}</span>
|
||||
) : null}
|
||||
{typeof details?.behind === 'number' && details.behind > 0 ? (
|
||||
<span className="text-[color:var(--status-warning)]">↓{details.behind}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditing && !isConfirming ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreateWorktreeForBranch(branchName)}
|
||||
disabled={disableCreateWorktree}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label={t('branchPickerDialog.actions.createWorktreeAria')}
|
||||
>
|
||||
{isCreatingWorktree ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="split-cells-horizontal" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree ? t('branchPickerDialog.tooltip.worktreeAlreadyExists') : t('branchPickerDialog.tooltip.createWorktree')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => beginRename(branchName)}
|
||||
disabled={disableRename}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label={t('branchPickerDialog.actions.renameAria')}
|
||||
>
|
||||
<Icon name="pencil" className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{isProjectRootBranch ? t('branchPickerDialog.tooltip.renameDisabledForRoot') : t('branchPickerDialog.tooltip.rename')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasAttachedWorktree) {
|
||||
handleRemoveWorktree(attachedWorktree);
|
||||
return;
|
||||
}
|
||||
setConfirmingDelete(branchName);
|
||||
}}
|
||||
disabled={hasAttachedWorktree ? disableWorktreeDelete : disableDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label={hasAttachedWorktree ? t('branchPickerDialog.actions.deleteWorktreeAria') : t('branchPickerDialog.actions.deleteAria')}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="delete-bin" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree
|
||||
? isProjectRootBranch
|
||||
? t('branchPickerDialog.tooltip.deleteWorktreeRootProtected')
|
||||
: t('branchPickerDialog.tooltip.deleteWorktree')
|
||||
: isCurrent
|
||||
? t('branchPickerDialog.tooltip.deleteCurrentBranch')
|
||||
: isProjectRootBranch
|
||||
? t('branchPickerDialog.tooltip.deleteDisabledForRoot')
|
||||
: t('branchPickerDialog.tooltip.delete')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void commitRename(branchName)}
|
||||
disabled={isRenaming}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label={t('branchPickerDialog.actions.confirmRenameAria')}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="check" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelRename}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t('branchPickerDialog.actions.cancelRenameAria')}
|
||||
>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isEditing && isConfirming && !hasAttachedWorktree ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className={cn(
|
||||
'text-xs mr-1',
|
||||
isForceDelete ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}>
|
||||
{isForceDelete ? t('branchPickerDialog.actions.forceDeletePrompt') : t('branchPickerDialog.actions.deletePrompt')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteBranch(branchName)}
|
||||
disabled={isDeleting}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:opacity-50',
|
||||
isForceDelete
|
||||
? 'bg-destructive/10 text-destructive hover:bg-destructive/15'
|
||||
: 'hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
|
||||
)}
|
||||
aria-label={t('branchPickerDialog.actions.confirmDeleteAria')}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Icon name="loader-4" className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Icon name="check" className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t('branchPickerDialog.actions.cancelDeleteAria')}
|
||||
>
|
||||
<Icon name="close" className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { opencodeClient, type FilesystemEntry } from '@/lib/opencode/client';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
|
||||
interface DirectoryAutocompleteProps {
|
||||
inputValue: string;
|
||||
homeDirectory: string | null;
|
||||
onSelectSuggestion: (path: string) => void;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export interface DirectoryAutocompleteHandle {
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => boolean;
|
||||
}
|
||||
|
||||
export const DirectoryAutocomplete = React.forwardRef<DirectoryAutocompleteHandle, DirectoryAutocompleteProps>(({
|
||||
inputValue,
|
||||
homeDirectory,
|
||||
onSelectSuggestion,
|
||||
visible,
|
||||
onClose,
|
||||
showHidden,
|
||||
}, ref) => {
|
||||
const [suggestions, setSuggestions] = React.useState<FilesystemEntry[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
// Fuzzy matching score - returns null if no match, higher score = better match
|
||||
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const c = candidate.toLowerCase();
|
||||
let score = 0;
|
||||
let lastIndex = -1;
|
||||
let consecutive = 0;
|
||||
|
||||
for (let i = 0; i < q.length; i += 1) {
|
||||
const ch = q[i];
|
||||
if (!ch || ch === ' ') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const idx = c.indexOf(ch, lastIndex + 1);
|
||||
if (idx === -1) {
|
||||
return null; // Character not found - no match
|
||||
}
|
||||
|
||||
const gap = idx - lastIndex - 1;
|
||||
if (gap === 0) {
|
||||
consecutive += 1;
|
||||
} else {
|
||||
consecutive = 0;
|
||||
}
|
||||
|
||||
score += 10; // Base score per matched char
|
||||
score += Math.max(0, 18 - idx); // Bonus for early matches
|
||||
score -= Math.max(0, gap); // Penalty for gaps
|
||||
|
||||
// Bonus for match at start or after separator
|
||||
if (idx === 0) {
|
||||
score += 12;
|
||||
} else {
|
||||
const prev = c[idx - 1];
|
||||
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
|
||||
score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches
|
||||
lastIndex = idx;
|
||||
}
|
||||
|
||||
score += Math.max(0, 24 - Math.round(c.length / 3)); // Shorter names score higher
|
||||
return score;
|
||||
}, []);
|
||||
|
||||
// Expand ~ to home directory
|
||||
const expandPath = React.useCallback((path: string): string => {
|
||||
if (path.startsWith('~') && homeDirectory) {
|
||||
return path.replace(/^~/, homeDirectory);
|
||||
}
|
||||
return path;
|
||||
}, [homeDirectory]);
|
||||
|
||||
// Get the directory part of the path for listing
|
||||
const getParentDir = React.useCallback((path: string): string => {
|
||||
const expanded = expandPath(path);
|
||||
// If ends with /, list that directory
|
||||
if (expanded.endsWith('/')) {
|
||||
return expanded;
|
||||
}
|
||||
// Otherwise, get parent directory
|
||||
const lastSlash = expanded.lastIndexOf('/');
|
||||
if (lastSlash === -1) return '';
|
||||
if (lastSlash === 0) return '/';
|
||||
return expanded.substring(0, lastSlash + 1);
|
||||
}, [expandPath]);
|
||||
|
||||
// Get the partial name being typed (for filtering)
|
||||
const getPartialName = React.useCallback((path: string): string => {
|
||||
const expanded = expandPath(path);
|
||||
if (expanded.endsWith('/')) return '';
|
||||
const lastSlash = expanded.lastIndexOf('/');
|
||||
if (lastSlash === -1) return expanded;
|
||||
return expanded.substring(lastSlash + 1);
|
||||
}, [expandPath]);
|
||||
|
||||
const debouncedInputValue = useDebouncedValue(inputValue, 150);
|
||||
|
||||
// Fetch directory suggestions
|
||||
React.useEffect(() => {
|
||||
if (!visible || !debouncedInputValue) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDir = getParentDir(debouncedInputValue);
|
||||
const partialName = getPartialName(debouncedInputValue).toLowerCase();
|
||||
|
||||
if (!parentDir) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
opencodeClient.listLocalDirectory(parentDir)
|
||||
.then((entries) => {
|
||||
if (cancelled) return;
|
||||
|
||||
// Filter to directories only, respect hidden setting
|
||||
const directories = entries.filter((entry) => {
|
||||
if (!entry.isDirectory) return false;
|
||||
if (!showHidden && entry.name.startsWith('.')) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Apply fuzzy matching and sort by score
|
||||
const scored = partialName
|
||||
? directories
|
||||
.map((entry) => {
|
||||
const score = fuzzyScore(partialName, entry.name);
|
||||
return score !== null ? { entry, score } : null;
|
||||
})
|
||||
.filter((item): item is { entry: FilesystemEntry; score: number } => item !== null)
|
||||
.sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name))
|
||||
.map((item) => item.entry)
|
||||
: directories.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
setSuggestions(scored.slice(0, 10)); // Limit suggestions
|
||||
setSelectedIndex(0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSuggestions([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, debouncedInputValue, getParentDir, getPartialName, showHidden, fuzzyScore]);
|
||||
|
||||
// Scroll selected item into view
|
||||
React.useEffect(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest'
|
||||
});
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Handle outside click
|
||||
React.useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target || !containerRef.current) return;
|
||||
if (containerRef.current.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
};
|
||||
}, [visible, onClose]);
|
||||
|
||||
const handleSelectSuggestion = React.useCallback((entry: FilesystemEntry) => {
|
||||
// Append the selected directory name to current path, with trailing slash
|
||||
const path = entry.path.endsWith('/') ? entry.path : entry.path + '/';
|
||||
onSelectSuggestion(path);
|
||||
}, [onSelectSuggestion]);
|
||||
|
||||
// Expose key handler to parent
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): boolean => {
|
||||
if (!visible || suggestions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const total = suggestions.length;
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
// Shift+Tab: previous suggestion
|
||||
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||
} else {
|
||||
// Tab: next suggestion or select if only one
|
||||
if (total === 1) {
|
||||
const selected = suggestions[0];
|
||||
if (selected) {
|
||||
handleSelectSuggestion(selected);
|
||||
}
|
||||
} else {
|
||||
setSelectedIndex((prev) => (prev + 1) % total);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev + 1) % total);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => (prev - 1 + total) % total);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Select current item and close autocomplete
|
||||
const safeIndex = ((selectedIndex % total) + total) % total;
|
||||
const selected = suggestions[safeIndex];
|
||||
if (selected) {
|
||||
handleSelectSuggestion(selected);
|
||||
}
|
||||
onClose();
|
||||
return true; // Consume the event, don't let parent confirm yet
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}), [visible, suggestions, selectedIndex, handleSelectSuggestion, onClose]);
|
||||
|
||||
if (!visible || (suggestions.length === 0 && !loading)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute z-[100] w-full max-h-48 bg-background border border-border rounded-lg shadow-none top-full mt-1 left-0 flex flex-col overflow-hidden"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<Icon name="refresh" className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto py-1">
|
||||
{suggestions.map((entry, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
ref={(el) => { itemRefs.current[index] = el; }}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label",
|
||||
isSelected && "bg-interactive-selection"
|
||||
)}
|
||||
onClick={() => { handleSelectSuggestion(entry); onClose(); }}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<Icon name="folder" className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 border-t typography-meta text-muted-foreground bg-sidebar/50">
|
||||
Tab cycle • ↑↓ navigate • Enter select
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DirectoryAutocomplete.displayName = 'DirectoryAutocomplete';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||
const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||
|
||||
const isSubtaskSession = (session: Session): boolean => {
|
||||
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
@@ -22,7 +22,7 @@ const getSessionUpdatedAt = (session: Session): number => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const sortSessionsByUpdated = (sessions: Session[]): Session[] => {
|
||||
const sortSessionsByUpdated = (sessions: Session[]): Session[] => {
|
||||
return [...sessions].sort((a, b) => getSessionUpdatedAt(b) - getSessionUpdatedAt(a));
|
||||
};
|
||||
|
||||
@@ -42,5 +42,3 @@ export const deriveRecentSessions = (
|
||||
});
|
||||
return sortSessionsByUpdated(recent);
|
||||
};
|
||||
|
||||
export const getSessionUpdatedAtMs = getSessionUpdatedAt;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath, resolveArchivedFolderName } from '../utils';
|
||||
|
||||
export type ProjectForArchivedFolders = {
|
||||
type ProjectForArchivedFolders = {
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -146,20 +146,6 @@ export const compareSessionsByPinnedAndTime = (
|
||||
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
||||
};
|
||||
|
||||
export const compareSessionsByPinnedAndCreated = (
|
||||
a: Session,
|
||||
b: Session,
|
||||
pinnedSessionIds: Set<string>,
|
||||
): number => {
|
||||
const aPinned = pinnedSessionIds.has(a.id);
|
||||
const bPinned = pinnedSessionIds.has(b.id);
|
||||
if (aPinned !== bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
}
|
||||
|
||||
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
|
||||
};
|
||||
|
||||
export const dedupeSessionsById = (sessions: Session[]): Session[] => {
|
||||
const byId = new Map<string, Session>();
|
||||
sessions.forEach((session) => {
|
||||
|
||||
Reference in New Issue
Block a user