feat: move projects to sidebar rail and speed up session switching (#506)
* feat: move projects from header tabs to left sidebar rail * refactor: remove variant from git generation session context * feat: implemented drandrop action for navrails * refactor: improve sidebar drag-and-drop smoothness and remove floating overlay * fix: prevent mobile nav rail touch from closing drawer and opening menu * fix: hide header tabs on desktop * fix: prevent session flicker when switching projects * style: soften chat panel divider borders * style: improve icon contrast across core navigation * fix: stabilize session selection during project switching * style: unify sidebar surfaces and transparent section layers * style: remove UI shadows and keep only scroll shadow * perf: speed up project switching with cached session loading * perf: speed up Git changes view and background refresh * perf: make session switching lighter and less aggressive * perf: optimized sessions list loading while project switching * perf: smooth chat rendering and reduce interaction spikes * fix: restore reliable load older messages visibility
This commit is contained in:
committed by
GitHub
parent
4c69bccf56
commit
10851bd7ac
@@ -97,6 +97,8 @@ type GitmojiCachePayload = {
|
||||
const GITMOJI_CACHE_KEY = 'gitmojiCache';
|
||||
const GITMOJI_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 7;
|
||||
const GITMOJI_CACHE_VERSION = '1';
|
||||
const GIT_DIFF_PRIORITY_PREFETCH_LIMIT = 40;
|
||||
const GIT_DIFF_PRIORITY_BASELINE_LIMIT = 20;
|
||||
const GITMOJI_SOURCE_URL =
|
||||
'https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json';
|
||||
|
||||
@@ -245,6 +247,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
fetchBranches,
|
||||
fetchLog,
|
||||
fetchIdentity,
|
||||
prefetchDiffs,
|
||||
setLogMaxCount,
|
||||
} = useGitStore();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
@@ -310,6 +313,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
const [commitMessage, setCommitMessage] = React.useState(
|
||||
initialSnapshot?.commitMessage ?? ''
|
||||
);
|
||||
const [visibleChangePaths, setVisibleChangePaths] = React.useState<string[]>([]);
|
||||
const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false);
|
||||
const actionPanelScrollRef = React.useRef<HTMLElement | null>(null);
|
||||
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
||||
@@ -620,10 +624,12 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
const dirState = useGitStore.getState().directories.get(currentDirectory);
|
||||
if (!dirState?.status) {
|
||||
fetchAll(currentDirectory, git, { force: true });
|
||||
void fetchAll(currentDirectory, git, { force: true });
|
||||
} else {
|
||||
void fetchStatus(currentDirectory, git, { silent: true });
|
||||
}
|
||||
}
|
||||
}, [currentDirectory, setActiveDirectory, fetchAll, git]);
|
||||
}, [currentDirectory, setActiveDirectory, fetchAll, fetchStatus, git]);
|
||||
|
||||
const refreshStatusAndBranches = React.useCallback(
|
||||
async (showErrors = true) => {
|
||||
@@ -706,6 +712,39 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [status]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory || changeEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderedPaths: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const pushPath = (path: string) => {
|
||||
if (!path || seen.has(path)) {
|
||||
return;
|
||||
}
|
||||
seen.add(path);
|
||||
orderedPaths.push(path);
|
||||
};
|
||||
|
||||
Array.from(selectedPaths).forEach(pushPath);
|
||||
visibleChangePaths.forEach(pushPath);
|
||||
changeEntries.slice(0, GIT_DIFF_PRIORITY_BASELINE_LIMIT).forEach((entry) => pushPath(entry.path));
|
||||
|
||||
if (orderedPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: GIT_DIFF_PRIORITY_PREFETCH_LIMIT });
|
||||
}, 120);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [changeEntries, currentDirectory, git, prefetchDiffs, selectedPaths, visibleChangePaths]);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!status || changeEntries.length === 0) {
|
||||
@@ -1628,7 +1667,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background" data-keyboard-avoid="true">
|
||||
<div className={cn('flex h-full flex-col overflow-hidden', isSidebarMode ? 'bg-transparent' : 'bg-background')} data-keyboard-avoid="true">
|
||||
<GitHeader
|
||||
status={status}
|
||||
localBranches={localBranches}
|
||||
@@ -1670,7 +1709,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<div className="h-full min-h-0 flex flex-col">
|
||||
<div className={cn('min-w-0 min-h-0 h-full bg-muted/10 flex flex-col', isSidebarMode && 'border-t border-border/40')}>
|
||||
<div className={cn('min-w-0 min-h-0 h-full flex flex-col', isSidebarMode ? 'bg-transparent border-t border-border/40' : 'bg-muted/10')}>
|
||||
<div className="px-3 py-1.5">
|
||||
<AnimatedTabs<ActionTab>
|
||||
value={actionTab}
|
||||
@@ -1704,6 +1743,7 @@ export const GitView: React.FC<GitViewProps> = ({ mode = 'full' }) => {
|
||||
variant="plain"
|
||||
maxListHeightClassName="max-h-[40vh]"
|
||||
changeEntries={changeEntries}
|
||||
onVisiblePathsChange={setVisibleChangePaths}
|
||||
selectedPaths={selectedPaths}
|
||||
diffStats={status?.diffStats}
|
||||
revertingPaths={revertingPaths}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChan
|
||||
className={cn(
|
||||
'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
|
||||
'w-[90vw] max-w-[960px] h-[85vh] max-h-[900px]',
|
||||
'rounded-xl border shadow-2xl overflow-hidden',
|
||||
'rounded-xl border shadow-none overflow-hidden',
|
||||
'bg-background'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const AIHighlightsBox: React.FC<AIHighlightsBoxProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-border/60 bg-background/60 px-3 py-2">
|
||||
<div className="space-y-2 rounded-xl border border-border/60 bg-transparent px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="typography-micro text-muted-foreground">AI highlights</p>
|
||||
<Tooltip delayDuration={1000}>
|
||||
|
||||
@@ -96,14 +96,13 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div
|
||||
className="group flex items-center gap-2 px-3 py-1.5 hover:bg-sidebar/40 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onViewDiff}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className="group flex items-center gap-2 px-3 py-1.5 hover:bg-sidebar/40 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onViewDiff}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleClick}
|
||||
@@ -175,7 +174,6 @@ export const ChangeRow = React.memo<ChangeRowProps>(function ChangeRow({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>Revert changes</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
||||
@@ -18,8 +19,12 @@ interface ChangesSectionProps {
|
||||
onRevertFile: (path: string) => void;
|
||||
variant?: 'framed' | 'plain';
|
||||
maxListHeightClassName?: string;
|
||||
onVisiblePathsChange?: (paths: string[]) => void;
|
||||
}
|
||||
|
||||
const CHANGE_LIST_VIRTUALIZE_THRESHOLD = 120;
|
||||
const CHANGE_ROW_ESTIMATE_PX = 34;
|
||||
|
||||
export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
changeEntries,
|
||||
selectedPaths,
|
||||
@@ -32,10 +37,43 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
onRevertFile,
|
||||
variant = 'framed',
|
||||
maxListHeightClassName,
|
||||
onVisiblePathsChange,
|
||||
}) => {
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const selectedCount = selectedPaths.size;
|
||||
const totalCount = changeEntries.length;
|
||||
const shouldVirtualize = totalCount >= CHANGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: totalCount,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => CHANGE_ROW_ESTIMATE_PX,
|
||||
overscan: 10,
|
||||
enabled: shouldVirtualize,
|
||||
});
|
||||
|
||||
const virtualRows = React.useMemo(
|
||||
() => (shouldVirtualize ? rowVirtualizer.getVirtualItems() : []),
|
||||
[rowVirtualizer, shouldVirtualize],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!onVisiblePathsChange) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
onVisiblePathsChange([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldVirtualize) {
|
||||
onVisiblePathsChange(changeEntries.slice(0, Math.min(30, totalCount)).map((entry) => entry.path));
|
||||
return;
|
||||
}
|
||||
|
||||
onVisiblePathsChange(virtualRows.map((row) => changeEntries[row.index]?.path).filter((value): value is string => Boolean(value)));
|
||||
}, [changeEntries, onVisiblePathsChange, shouldVirtualize, totalCount, virtualRows]);
|
||||
|
||||
const containerClassName =
|
||||
variant === 'framed'
|
||||
@@ -86,20 +124,54 @@ export const ChangesSection: React.FC<ChangesSectionProps> = ({
|
||||
ref={scrollRef}
|
||||
className="overlay-scrollbar-target overlay-scrollbar-container flex-1 min-h-0 w-full overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{changeEntries.map((file) => (
|
||||
<ChangeRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
checked={selectedPaths.has(file.path)}
|
||||
stats={diffStats?.[file.path]}
|
||||
onToggle={() => onToggleFile(file.path)}
|
||||
onViewDiff={() => onViewDiff(file.path)}
|
||||
onRevert={() => onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{shouldVirtualize ? (
|
||||
<div
|
||||
className="relative w-full divide-y divide-border/60"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualRows.map((row) => {
|
||||
const file = changeEntries[row.index];
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.path}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ transform: `translateY(${row.start}px)` }}
|
||||
>
|
||||
<ChangeRow
|
||||
file={file}
|
||||
checked={selectedPaths.has(file.path)}
|
||||
stats={diffStats?.[file.path]}
|
||||
onToggle={() => onToggleFile(file.path)}
|
||||
onViewDiff={() => onViewDiff(file.path)}
|
||||
onRevert={() => onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60" role="list" aria-label="Changed files">
|
||||
{changeEntries.map((file) => (
|
||||
<ChangeRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
checked={selectedPaths.has(file.path)}
|
||||
stats={diffStats?.[file.path]}
|
||||
onToggle={() => onToggleFile(file.path)}
|
||||
onViewDiff={() => onViewDiff(file.path)}
|
||||
onRevert={() => onRevertFile(file.path)}
|
||||
isReverting={revertingPaths.has(file.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
<OverlayScrollbar containerRef={scrollRef} disableHorizontal />
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export const CommitInput: React.FC<CommitInputProps> = ({
|
||||
autoCapitalize={hasTouchInput ? 'sentences' : 'off'}
|
||||
spellCheck={hasTouchInput ? true : false}
|
||||
className={cn(
|
||||
'rounded-lg bg-background/80 resize-none overflow-y-auto',
|
||||
'rounded-lg bg-transparent resize-none overflow-y-auto',
|
||||
disabled && 'opacity-50'
|
||||
)}
|
||||
style={{ minHeight: MIN_HEIGHT, maxHeight: MAX_HEIGHT }}
|
||||
|
||||
@@ -285,7 +285,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
|
||||
if (useTwoRowHeader) {
|
||||
return (
|
||||
<header className="@container/git-header border-b border-border/40 px-3 py-2 bg-background">
|
||||
<header className={`@container/git-header border-b border-border/40 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'bg-background'}`}>
|
||||
<div className="flex items-center justify-between gap-2 min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
{isWorktreeMode ? (
|
||||
@@ -320,7 +320,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="@container/git-header flex items-center gap-2 border-b border-border/40 px-3 py-2 bg-background">
|
||||
<header className={`@container/git-header flex items-center gap-2 border-b border-border/40 px-3 py-2 ${isSidebarMode ? 'bg-transparent' : 'bg-background'}`}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
{isWorktreeMode ? (
|
||||
<WorktreeBranchDisplay
|
||||
|
||||
@@ -698,7 +698,7 @@ export const PullRequestSection: React.FC<{
|
||||
</div>
|
||||
) : null}
|
||||
{run.output?.text ? (
|
||||
<div className="rounded border border-border/40 bg-background/40 px-2 py-2 typography-micro text-muted-foreground whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
<div className="rounded border border-border/40 bg-transparent px-2 py-2 typography-micro text-muted-foreground whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{run.output.text}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -776,7 +776,7 @@ export const PullRequestSection: React.FC<{
|
||||
{step.conclusion ? <span className="ml-auto flex-shrink-0">{step.conclusion}</span> : null}
|
||||
</button>
|
||||
<CollapsibleContent>
|
||||
<div className="ml-6 mt-1 rounded border border-border/40 bg-background/40 px-2 py-2 typography-micro text-muted-foreground space-y-1">
|
||||
<div className="ml-6 mt-1 rounded border border-border/40 bg-transparent px-2 py-2 typography-micro text-muted-foreground space-y-1">
|
||||
{typeof step.number === 'number' ? <div>Step: {step.number}</div> : null}
|
||||
{step.status ? <div>Status: {step.status}</div> : null}
|
||||
{step.conclusion ? <div>Conclusion: {step.conclusion}</div> : null}
|
||||
@@ -1304,7 +1304,7 @@ export const PullRequestSection: React.FC<{
|
||||
|
||||
const containerClassName =
|
||||
variant === 'framed'
|
||||
? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden'
|
||||
? 'rounded-xl border border-border/60 bg-transparent overflow-hidden'
|
||||
: 'border-0 bg-transparent rounded-none';
|
||||
const headerClassName =
|
||||
variant === 'framed'
|
||||
|
||||
Reference in New Issue
Block a user