mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-07 05:45:39 +08:00
feat: introduce pages and fix bug on branch switching
This commit is contained in:
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import {
|
import {
|
||||||
ArrowDownToLine,
|
ArrowDownToLine,
|
||||||
ArrowUpFromLine,
|
ArrowUpFromLine,
|
||||||
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Settings,
|
Settings,
|
||||||
SunMoon,
|
SunMoon,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +38,15 @@ import { useSessionMessageSearch } from './sources/useSessionMessageSearch';
|
|||||||
import { useBranchesSource } from './sources/useBranchesSource';
|
import { useBranchesSource } from './sources/useBranchesSource';
|
||||||
import { useGitActions } from './sources/useGitActions';
|
import { useGitActions } from './sources/useGitActions';
|
||||||
|
|
||||||
type Mode = 'mixed' | 'actions' | 'files' | 'commits';
|
type Page = 'actions' | 'files' | 'sessions' | 'commits' | 'branches';
|
||||||
|
|
||||||
|
const PAGE_LABELS: Record<Page, string> = {
|
||||||
|
actions: 'Actions',
|
||||||
|
files: 'Files',
|
||||||
|
sessions: 'Sessions',
|
||||||
|
commits: 'Commits',
|
||||||
|
branches: 'Branches',
|
||||||
|
};
|
||||||
|
|
||||||
type CommandPaletteProps = {
|
type CommandPaletteProps = {
|
||||||
selectedProject: Project | null;
|
selectedProject: Project | null;
|
||||||
@@ -45,13 +55,6 @@ type CommandPaletteProps = {
|
|||||||
onShowTab?: (tab: AppTab) => void;
|
onShowTab?: (tab: AppTab) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseMode(input: string): { mode: Mode; query: string } {
|
|
||||||
if (input.startsWith('> ')) return { mode: 'actions', query: input.slice(2) };
|
|
||||||
if (input.startsWith('/')) return { mode: 'files', query: input.slice(1) };
|
|
||||||
if (input.startsWith('#')) return { mode: 'commits', query: input.slice(1) };
|
|
||||||
return { mode: 'mixed', query: input };
|
|
||||||
}
|
|
||||||
|
|
||||||
const NAV_TABS: Array<{ id: AppTab; label: string; keywords: string }> = [
|
const NAV_TABS: Array<{ id: AppTab; label: string; keywords: string }> = [
|
||||||
{ id: 'chat', label: 'Go to Chat', keywords: 'chat messages conversation' },
|
{ id: 'chat', label: 'Go to Chat', keywords: 'chat messages conversation' },
|
||||||
{ id: 'files', label: 'Go to Files', keywords: 'files file tree explorer' },
|
{ id: 'files', label: 'Go to Files', keywords: 'files file tree explorer' },
|
||||||
@@ -68,10 +71,13 @@ export default function CommandPalette({
|
|||||||
}: CommandPaletteProps) {
|
}: CommandPaletteProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
|
const [pages, setPages] = React.useState<Page[]>([]);
|
||||||
const { toggleDarkMode } = useTheme();
|
const { toggleDarkMode } = useTheme();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const ops = usePaletteOps();
|
const ops = usePaletteOps();
|
||||||
|
|
||||||
|
const page = pages.at(-1);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
const isCmdK = (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k';
|
const isCmdK = (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k';
|
||||||
@@ -84,22 +90,25 @@ export default function CommandPalette({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) setSearch('');
|
if (!open) {
|
||||||
|
setSearch('');
|
||||||
|
setPages([]);
|
||||||
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const { mode, query } = parseMode(search);
|
|
||||||
const projectId = selectedProject?.projectId;
|
const projectId = selectedProject?.projectId;
|
||||||
|
|
||||||
const showActions = mode === 'mixed' || mode === 'actions';
|
const showActions = !page || page === 'actions';
|
||||||
const showSessions = mode === 'mixed';
|
const showSessions = !page || page === 'sessions';
|
||||||
const showFiles = mode === 'mixed' || mode === 'files';
|
const showFiles = !page || page === 'files';
|
||||||
const showCommits = mode === 'mixed' || mode === 'commits';
|
const showCommits = !page || page === 'commits';
|
||||||
|
const showBranches = !page || page === 'branches' || page === 'actions';
|
||||||
|
|
||||||
const sessions = useSessionsSource(projectId, open && showSessions);
|
const sessions = useSessionsSource(projectId, open && showSessions);
|
||||||
const messageMatches = useSessionMessageSearch(projectId, query, open && showSessions);
|
const messageMatches = useSessionMessageSearch(projectId, search, open && showSessions);
|
||||||
const files = useFilesSource(projectId, open && showFiles);
|
const files = useFilesSource(projectId, open && showFiles);
|
||||||
const commits = useCommitsSource(projectId, open && showCommits);
|
const commits = useCommitsSource(projectId, open && showCommits);
|
||||||
const branches = useBranchesSource(projectId, open && showActions);
|
const branches = useBranchesSource(projectId, open && showBranches);
|
||||||
const git = useGitActions(projectId);
|
const git = useGitActions(projectId);
|
||||||
|
|
||||||
const sessionRows = React.useMemo(() => {
|
const sessionRows = React.useMemo(() => {
|
||||||
@@ -125,26 +134,58 @@ export default function CommandPalette({
|
|||||||
return Array.from(byId.values());
|
return Array.from(byId.values());
|
||||||
}, [sessions, messageMatches, showSessions]);
|
}, [sessions, messageMatches, showSessions]);
|
||||||
|
|
||||||
const filter = React.useCallback((value: string, rawSearch: string) => {
|
|
||||||
const stripped = parseMode(rawSearch).query.trim().toLowerCase();
|
|
||||||
if (!stripped) return 1;
|
|
||||||
return value.toLowerCase().includes(stripped) ? 1 : 0;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const run = React.useCallback((fn: () => void) => {
|
const run = React.useCallback((fn: () => void) => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
fn();
|
fn();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const pushPage = React.useCallback((next: Page) => {
|
||||||
|
setSearch('');
|
||||||
|
setPages((prev) => [...prev, next]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const popPage = React.useCallback(() => {
|
||||||
|
setSearch('');
|
||||||
|
setPages((prev) => prev.slice(0, -1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Backspace' && !search && pages.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
popPage();
|
||||||
|
}
|
||||||
|
}, [search, pages.length, popPage]);
|
||||||
|
|
||||||
const startNewChatDisabled = !selectedProject;
|
const startNewChatDisabled = !selectedProject;
|
||||||
|
const browseLimit = 5;
|
||||||
|
const filesShown = page === 'files' ? files : files.slice(0, browseLimit);
|
||||||
|
const commitsShown = page === 'commits' ? commits : commits.slice(0, browseLimit);
|
||||||
|
const sessionsShown = page === 'sessions' ? sessionRows : sessionRows.slice(0, browseLimit);
|
||||||
|
const branchesShown = page === 'branches' ? branches : branches.slice(0, browseLimit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="max-w-xl overflow-hidden p-0">
|
<DialogContent className="max-w-xl overflow-hidden p-0">
|
||||||
<DialogTitle>Command palette</DialogTitle>
|
<DialogTitle>Command palette</DialogTitle>
|
||||||
<Command label="Command palette" filter={filter}>
|
<Command label="Command palette" onKeyDown={handleKeyDown}>
|
||||||
|
{page && (
|
||||||
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-md bg-accent px-2 py-0.5 text-xs font-medium text-accent-foreground">
|
||||||
|
{PAGE_LABELS[page]}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={popPage}
|
||||||
|
aria-label="Back to all"
|
||||||
|
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">Backspace to go back</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Type to search — prefix with > for actions, / for files, # for commits"
|
placeholder={page ? `Search ${PAGE_LABELS[page].toLowerCase()}…` : 'Type to search anything…'}
|
||||||
value={search}
|
value={search}
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
/>
|
/>
|
||||||
@@ -215,19 +256,6 @@ export default function CommandPalette({
|
|||||||
<ArrowUpFromLine className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
<ArrowUpFromLine className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
<span className="flex-1">Git: Push</span>
|
<span className="flex-1">Git: Push</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
{branches
|
|
||||||
.filter((b) => !b.isCurrent && !b.isRemote)
|
|
||||||
.slice(0, 30)
|
|
||||||
.map((b) => (
|
|
||||||
<CommandItem
|
|
||||||
key={`branch-${b.name}`}
|
|
||||||
value={`Switch to branch ${b.name}`}
|
|
||||||
onSelect={() => run(() => { void git.checkout(b.name); onShowTab?.('git'); })}
|
|
||||||
>
|
|
||||||
<GitMerge className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
||||||
<span className="flex-1 truncate">Switch to branch: {b.name}</span>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -246,12 +274,12 @@ export default function CommandPalette({
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSessions && projectId && sessionRows.length > 0 && (
|
{showSessions && projectId && sessionsShown.length > 0 && (
|
||||||
<CommandGroup heading="Sessions">
|
<CommandGroup heading="Sessions">
|
||||||
{sessionRows.map((s) => (
|
{sessionsShown.map((s) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={s.id}
|
key={s.id}
|
||||||
value={`${s.label} ${s.snippet ?? ''}`.trim()}
|
value={`session-${s.id} ${s.label} ${s.snippet ?? ''}`.trim()}
|
||||||
onSelect={() => run(() => navigate(`/session/${s.id}`))}
|
onSelect={() => run(() => navigate(`/session/${s.id}`))}
|
||||||
>
|
>
|
||||||
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
@@ -266,15 +294,18 @@ export default function CommandPalette({
|
|||||||
)}
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
{!page && sessionRows.length > browseLimit && (
|
||||||
|
<BrowseAllItem label={`Browse all sessions (${sessionRows.length})`} onSelect={() => pushPage('sessions')} />
|
||||||
|
)}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showFiles && projectId && files.length > 0 && (
|
{showFiles && projectId && filesShown.length > 0 && (
|
||||||
<CommandGroup heading="Files">
|
<CommandGroup heading="Files">
|
||||||
{files.map((f) => (
|
{filesShown.map((f) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={f.path}
|
key={f.path}
|
||||||
value={f.path}
|
value={`file-${f.path}`}
|
||||||
onSelect={() => run(() => ops.openFile(f.path))}
|
onSelect={() => run(() => ops.openFile(f.path))}
|
||||||
>
|
>
|
||||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
@@ -282,15 +313,18 @@ export default function CommandPalette({
|
|||||||
<span className="truncate text-xs text-muted-foreground">{f.path}</span>
|
<span className="truncate text-xs text-muted-foreground">{f.path}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
{!page && files.length > browseLimit && (
|
||||||
|
<BrowseAllItem label={`Browse all files (${files.length})`} onSelect={() => pushPage('files')} />
|
||||||
|
)}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showCommits && projectId && commits.length > 0 && (
|
{showCommits && projectId && commitsShown.length > 0 && (
|
||||||
<CommandGroup heading="Commits">
|
<CommandGroup heading="Commits">
|
||||||
{commits.map((c) => (
|
{commitsShown.map((c) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={c.hash}
|
key={c.hash}
|
||||||
value={`${c.shortHash} ${c.message} ${c.author}`}
|
value={`commit-${c.hash} ${c.message} ${c.author}`}
|
||||||
onSelect={() => run(() => onShowTab?.('git'))}
|
onSelect={() => run(() => onShowTab?.('git'))}
|
||||||
>
|
>
|
||||||
<GitCommit className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
<GitCommit className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
@@ -299,6 +333,27 @@ export default function CommandPalette({
|
|||||||
<span className="truncate text-xs text-muted-foreground">{c.author}</span>
|
<span className="truncate text-xs text-muted-foreground">{c.author}</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
|
{!page && commits.length > browseLimit && (
|
||||||
|
<BrowseAllItem label={`Browse all commits (${commits.length})`} onSelect={() => pushPage('commits')} />
|
||||||
|
)}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showBranches && projectId && branchesShown.length > 0 && (
|
||||||
|
<CommandGroup heading="Branches">
|
||||||
|
{branchesShown.map((b) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`branch-${b.name}`}
|
||||||
|
value={`branch-${b.name}`}
|
||||||
|
onSelect={() => run(() => { void git.checkout(b.name); onShowTab?.('git'); })}
|
||||||
|
>
|
||||||
|
<GitMerge className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="flex-1 truncate">Switch to: {b.name}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
{!page && branches.length > browseLimit && (
|
||||||
|
<BrowseAllItem label={`Browse all branches (${branches.length})`} onSelect={() => pushPage('branches')} />
|
||||||
|
)}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
@@ -307,3 +362,12 @@ export default function CommandPalette({
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BrowseAllItem({ label, onSelect }: { label: string; onSelect: () => void }) {
|
||||||
|
return (
|
||||||
|
<CommandItem value={`browse-${label}`} onSelect={onSelect}>
|
||||||
|
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="flex-1 text-muted-foreground">{label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,14 +2,10 @@ import { authenticatedFetch } from '../../../utils/api';
|
|||||||
|
|
||||||
import { useApiSource } from './useApiSource';
|
import { useApiSource } from './useApiSource';
|
||||||
|
|
||||||
export type BranchResult = {
|
export type BranchResult = { name: string };
|
||||||
name: string;
|
|
||||||
isCurrent: boolean;
|
|
||||||
isRemote: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BranchesResponse {
|
interface BranchesResponse {
|
||||||
branches?: Array<{ name: string; current?: boolean; isRemote?: boolean }>;
|
localBranches?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBranchesSource(projectId: string | undefined, enabled: boolean) {
|
export function useBranchesSource(projectId: string | undefined, enabled: boolean) {
|
||||||
@@ -20,13 +16,6 @@ export function useBranchesSource(projectId: string | undefined, enabled: boolea
|
|||||||
const params = new URLSearchParams({ project: projectId! });
|
const params = new URLSearchParams({ project: projectId! });
|
||||||
return authenticatedFetch(`/api/git/branches?${params.toString()}`, { signal });
|
return authenticatedFetch(`/api/git/branches?${params.toString()}`, { signal });
|
||||||
},
|
},
|
||||||
parse: (data) => {
|
parse: (data) => (data.localBranches ?? []).map((name) => ({ name })),
|
||||||
const list = data.branches ?? [];
|
|
||||||
return list.map<BranchResult>((b) => ({
|
|
||||||
name: b.name,
|
|
||||||
isCurrent: Boolean(b.current),
|
|
||||||
isRemote: Boolean(b.isRemote),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user