perf(right-sidebar): gate live effects, memoize lookups, always-mount tabs (#1674)

* perf(right-sidebar): gate live effects, memoize lookups, always-mount tabs

Performance fixes for the right sidebar (git/files/context tabs).

== Correctness / leak fixes (P0)

* RightSidebar: drop dead useEffect that re-nulled refs the resize
  handler already nulled; collapse the redundant width/minWidth/maxWidth
  triple into width + the existing --oc-right-sidebar-width variable.
* useUIStore: clamp setRightSidebarWidth to [MIN, MAX]; simplify
  setRightSidebarOpen (22 lines -> 12).
* RightSidebarTabs: useRightSidebarGitSync now takes the right tab and
  main tab and only polls when the right git tab is the visible consumer
  and the browser is online + visible. Replaces a global poll that
  fired for the lifetime of the open sidebar.
* GitView: commit-files fetch refactored to cancelled + Promise.all
  (was a per-hash loop that could setState after unmount); getRemoteUrl
  and refreshRemotes gated on cancelled/mountedRef; new module-scoped
  mountedRef guards setIsSettingIdentity from firing after unmount.
* GitView + useGitmojiList: extract gitmoji fetch/cache into a hook
  with module-level inflight promise + subscribers Set; stale-while-
  revalidate from localStorage; ensureLoaded() for call-site-initiated
  hydration; cancelled flag on setIsLoading to avoid the React
  setState-after-unmount race.
* ProjectNotesTodoPanel: 400 ms notes debounce now cancels on blur
  (was double-saving); persistProjectData chained per project through
  a module-level Map<projectId, Promise> so a fast todo toggle racing
  the debounced save no longer hits the server in parallel; resize
  auto-adjust guards against same-value pings.

== Render fanout (P1)

* RightSidebarTabs: all three tab content components are now always
  mounted with the hidden attribute. State and cache survive tab
  switches. When activeMainTab === 'git' (or 'context') the matching
  right tab is filtered out of the tab strip and a redirect effect
  snaps any persisted-but-now-hidden right tab to 'files'. onSelect is
  now a type-guarded handler instead of `as RightTab`.
* GitView: 13 separate useGitStore action selectors collapsed into one
  useShallow block (one re-evaluation per store change instead of 13).
* GitView: new isGitViewActive flag (true when this instance is the
  visible consumer) gates the 7 live effects — load identities, fetch
  remote URL, refresh remotes, ensureAll, sessionEvents.onGitRefreshHint,
  worktree bootstrap poll, default-identity auto-apply. Hidden
  GitView instances no longer run these.
* GitView: gitViewSnapshots module-level Map is now backed by an
  LRU wrapper (cap 20) so per-directory draft snapshots cannot leak
  across hundreds of project switches. Removed the dead `unique.set`
  dedup in changeEntries — GitStatus.files is already unique by path.
* SidebarFilesTree: statusByPath Map<path, FileStatus> and
  badgeByDir Map<dirPath, { modified, added }> are precomputed once
  per gitStatus change. Tree render is O(1) per node instead of O(N)
  per node via the previous per-row find/scan. badgeByDir walks each
  file's path segments and increments counters for every ancestor
  dir, so total cost is O(N + total_dirs_in_files) per gitStatus
  change.
* SidebarFilesTree: FileRow wrapped in React.memo with a custom
  comparator. Context-menu open state moved INTO FileRow as local
  state — opening a menu in one row no longer re-renders siblings.
* SidebarFilesTree: loadDirectory accepts an isCancelled predicate;
  the batch-load effect for expandedPaths passes a stable predicate
  so per-dir fetches stop touching state once the effect tears down.
* SidebarFilesTree: module-level fileTreeCacheByRoot Map (LRU,
  cap 8 roots) hydrates childrenByDir / loadErrorsByDir /
  loadedDirsRef on mount or root change. Mirror effects write state
  back to the cache. Survives close-and-reopen of the right sidebar;
  populated entries are dropped on unmount only when they had no
  data.

== Result

Net diff: 6 files modified, 1 new (useGitmojiList.ts), 682 insertions,
283 deletions. Existing test suite baseline preserved (537 pass / 58
fail / 1 error) — no new regressions. The 58 pre-existing failures are
in unrelated chat/streaming tests and were verified via git stash on
the same branch.

Architecture assumptions, verified by manual review:
- P1.1's redirect effect snaps rightSidebarTab to 'files' whenever
  activeMainTab === 'git', so the right and main GitView instances
  are mutually exclusive — isGitViewActive cannot be true for both.
- The 7 gated effects plus the useRightSidebar GitSync poll cover all
  cases where git state should advance: visible consumer fetches; the
  poll keeps the store warm when only the right git tab is visible.
- The aborted loadDirectory predicate is sufficient because
  inFlightDirsRef and loadedDirsRef dedup at the call site before
  any network IO is initiated.

* fix(sidebar): always clean up inFlightDirsRef regardless of cancellation

* refactor(sidebar): deduplicate RIGHT_SIDEBAR_MIN/MAX_WIDTH constants, export from useUIStore

* docs: split right sidebar perf plan into standalone file, clean up merged master status from chat plan

* fix(git): gate GitView effects by instance visibility

---------

Co-authored-by: Leonid Skorobogatyy <bash@opencode.itc.local>
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
bashrusakh
2026-06-16 13:52:05 +03:00
committed by GitHub
co-authored by Leonid Skorobogatyy Bohdan Triapitsyn
parent e982bd9388
commit 9199798a14
10 changed files with 1273 additions and 291 deletions
@@ -361,7 +361,7 @@ export const MainLayout: React.FC = () => {
case 'plan':
return <React.Suspense fallback={null}><PlanView /></React.Suspense>;
case 'git':
return <React.Suspense fallback={null}><GitView /></React.Suspense>;
return <React.Suspense fallback={null}><GitView isActive={!mobileRightSidebarOpen} /></React.Suspense>;
case 'diff':
return <React.Suspense fallback={null}><DiffView /></React.Suspense>;
case 'terminal':
@@ -375,7 +375,7 @@ export const MainLayout: React.FC = () => {
default:
return null;
}
}, [activeMainTab]);
}, [activeMainTab, mobileRightSidebarOpen]);
const isChatActive = activeMainTab === 'chat';
@@ -467,7 +467,7 @@ export const MainLayout: React.FC = () => {
{mobileRightDrawerVisible && (
<motion.div className="absolute inset-0 z-20 bg-sidebar" data-page-scroll-lock="true" style={{ x: rightDrawerX }} aria-hidden={!mobileRightSidebarOpen}>
<ErrorBoundary>
<React.Suspense fallback={null}><GitView /></React.Suspense>
<React.Suspense fallback={null}><GitView isActive={mobileRightSidebarOpen} /></React.Suspense>
</ErrorBoundary>
</motion.div>
)}
@@ -1,11 +1,9 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { useUIStore, RIGHT_SIDEBAR_MIN_WIDTH, RIGHT_SIDEBAR_MAX_WIDTH } from '@/stores/useUIStore';
import { useI18n } from '@/lib/i18n';
export const RIGHT_SIDEBAR_CONTENT_WIDTH = 420;
const RIGHT_SIDEBAR_MIN_WIDTH = 360;
const RIGHT_SIDEBAR_MAX_WIDTH = 860;
interface RightSidebarProps {
isOpen: boolean;
@@ -96,13 +94,6 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, cl
setRightSidebarWidth(finalWidth);
};
React.useEffect(() => {
if (!isResizing) {
resizingWidthRef.current = null;
activeResizePointerIDRef.current = null;
}
}, [isResizing]);
const currentWidth = isResizing ? (resizingWidthRef.current ?? appliedWidth) : appliedWidth;
return (
@@ -116,11 +107,9 @@ export const RightSidebar: React.FC<RightSidebarProps> = ({ isOpen, children, cl
)}
style={{
width: `${currentWidth}px`,
minWidth: `${currentWidth}px`,
maxWidth: `${currentWidth}px`,
['--oc-right-sidebar-width' as string]: `${isResizing ? currentWidth : openWidth}px`,
overflowX: 'clip',
transitionProperty: isResizing ? 'none' : 'width, min-width, max-width',
transitionProperty: isResizing ? 'none' : 'width',
transitionDuration: '200ms',
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
}}
@@ -10,34 +10,62 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useUIStore } from '@/stores/useUIStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { formatDirectoryName } from '@/lib/utils';
import { formatDirectoryName, cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { SidebarFilesTree } from './SidebarFilesTree';
type RightTab = 'git' | 'files' | 'context';
const isRightTab = (value: string): value is RightTab =>
value === 'git' || value === 'files' || value === 'context';
const RIGHT_TAB_FALLBACK: RightTab = 'files';
const isBrowserActive = (): boolean => {
if (typeof document !== 'undefined' && document.hidden) return false;
if (typeof navigator !== 'undefined' && !navigator.onLine) return false;
return true;
};
/**
* Keeps git status fresh while the right sidebar is open.
* Replaces the GitPollingProvider removed in commit b2d5ccb4.
* The previous polling ran globally; now we only refresh when the sidebar is open.
* Keeps git status fresh while the right sidebar's Git tab is the visible
* consumer. Replaces the GitPollingProvider removed in commit b2d5ccb4.
*
* Gating rules (mirror the right-sidebar render policy):
* - sidebar must be open
* - right tab must be 'git' (otherwise GitView is not the visible consumer)
* - main tab must not be 'git' (otherwise secondaryView's GitView handles
* refresh and this poll would duplicate work)
* - browser must be visible + online
*
* Any condition flip resets the interval so the next tick starts fresh.
*/
function useRightSidebarGitSync(directory: string | undefined, isSidebarOpen: boolean) {
function useRightSidebarGitSync(
directory: string | undefined,
isSidebarOpen: boolean,
rightTab: RightTab | undefined,
mainTab: string | undefined
) {
const { git } = useRuntimeAPIs();
const ensureStatus = useGitStore((state) => state.ensureStatus);
const shouldPoll = Boolean(
directory && git && isSidebarOpen && rightTab === 'git' && mainTab !== 'git'
);
React.useEffect(() => {
if (!directory || !git || !isSidebarOpen) return;
if (!shouldPoll || !directory || !git) return;
void ensureStatus(directory, git);
const POLL_INTERVAL = 10_000;
const id = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return;
const id = window.setInterval(() => {
if (!isBrowserActive()) return;
void ensureStatus(directory, git);
}, POLL_INTERVAL);
return () => clearInterval(id);
}, [directory, git, isSidebarOpen, ensureStatus]);
return () => window.clearInterval(id);
}, [shouldPoll, directory, git, ensureStatus]);
}
export const ProjectContextPanel: React.FC = () => {
@@ -95,9 +123,31 @@ export const RightSidebarTabs: React.FC = () => {
const rightSidebarTab = useUIStore((state) => state.rightSidebarTab);
const setRightSidebarTab = useUIStore((state) => state.setRightSidebarTab);
const isRightSidebarOpen = useUIStore((state) => state.isRightSidebarOpen);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const directory = useEffectiveDirectory();
useRightSidebarGitSync(directory, isRightSidebarOpen);
useRightSidebarGitSync(directory, isRightSidebarOpen, rightSidebarTab, activeMainTab);
// When the main view already hosts a right-tab equivalent (e.g. main tab
// 'git' renders GitView in the secondary slot), the right sidebar's
// matching tab is hidden to avoid two live GitView instances running
// effects. The map is small and stable; expand it if more shared
// secondary/right views are added.
const hiddenRightTab: RightTab | null =
activeMainTab === 'git'
? 'git'
: activeMainTab === 'context'
? 'context'
: null;
// Persisted right sidebar tab can be stale across main-tab switches (e.g.
// user opened main 'git' while right tab was 'git'). Snap to the fallback
// so the visible tab never equals the hidden one.
React.useEffect(() => {
if (hiddenRightTab && rightSidebarTab === hiddenRightTab) {
setRightSidebarTab(RIGHT_TAB_FALLBACK);
}
}, [hiddenRightTab, rightSidebarTab, setRightSidebarTab]);
const tabItems = React.useMemo(() => [
{
@@ -117,13 +167,28 @@ export const RightSidebarTabs: React.FC = () => {
},
], [t]);
const visibleTabItems = React.useMemo(
() => (hiddenRightTab ? tabItems.filter((item) => item.id !== hiddenRightTab) : tabItems),
[tabItems, hiddenRightTab]
);
const isRightGitTabActive = isRightSidebarOpen && rightSidebarTab === 'git' && hiddenRightTab !== 'git';
const handleTabSelect = React.useCallback(
(tabID: string) => {
if (isRightTab(tabID)) {
setRightSidebarTab(tabID);
}
},
[setRightSidebarTab]
);
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<div className="h-9 bg-background pt-1 px-2">
<SortableTabsStrip
items={tabItems}
items={visibleTabItems}
activeId={rightSidebarTab}
onSelect={(tabID) => setRightSidebarTab(tabID as RightTab)}
onSelect={handleTabSelect}
layoutMode="fit"
variant="active-pill"
className="h-full"
@@ -131,9 +196,15 @@ export const RightSidebarTabs: React.FC = () => {
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{rightSidebarTab === 'git' && <GitView />}
{rightSidebarTab === 'files' && <SidebarFilesTree />}
{rightSidebarTab === 'context' && <ProjectContextPanel />}
<div className={cn('h-full', rightSidebarTab !== 'git' && 'hidden')}>
<GitView isActive={isRightGitTabActive} />
</div>
<div className={cn('h-full', rightSidebarTab !== 'files' && 'hidden')}>
<SidebarFilesTree />
</div>
<div className={cn('h-full', rightSidebarTab !== 'context' && 'hidden')}>
<ProjectContextPanel />
</div>
</div>
</div>
);
@@ -104,6 +104,61 @@ const shouldIgnorePath = (path: string): boolean => {
return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/');
};
// Module-level per-root cache for the file tree. After P1.1 the component
// stays mounted across right-sidebar tab switches, so the cache also stays
// warm during that flow. The cache also survives the close-and-reopen flow
// (the component remounts but the Map is module-scoped) — without this, every
// sidebar reopen would re-list every expanded directory.
//
// LRU by touchedAt; cap is generous because large repos can have hundreds
// of expanded directories and each FileNode is small (~80 bytes). Stale
// roots are evicted on the next touch.
type FileTreeCache = {
childrenByDir: Record<string, FileNode[]>;
loadErrorsByDir: Record<string, string>;
loadedDirs: Set<string>;
touchedAt: number;
};
const FILE_TREE_CACHE_MAX_ROOTS = 8;
const fileTreeCacheByRoot = new Map<string, FileTreeCache>();
const touchCache = (root: string): FileTreeCache | null => {
const entry = fileTreeCacheByRoot.get(root);
if (!entry) return null;
entry.touchedAt = Date.now();
// Touch on read promotes the key to the end of the Map's iteration order,
// so the oldest (front) entry is the next eviction candidate.
fileTreeCacheByRoot.delete(root);
fileTreeCacheByRoot.set(root, entry);
return entry;
};
const getOrCreateCache = (root: string): FileTreeCache => {
const existing = fileTreeCacheByRoot.get(root);
if (existing) {
existing.touchedAt = Date.now();
return existing;
}
if (fileTreeCacheByRoot.size >= FILE_TREE_CACHE_MAX_ROOTS) {
const oldest = fileTreeCacheByRoot.keys().next().value;
if (oldest !== undefined) {
fileTreeCacheByRoot.delete(oldest);
}
}
const created: FileTreeCache = {
childrenByDir: {},
loadErrorsByDir: {},
loadedDirs: new Set(),
touchedAt: Date.now(),
};
fileTreeCacheByRoot.set(root, created);
return created;
};
const dropCacheForRoot = (root: string): void => {
fileTreeCacheByRoot.delete(root);
};
const getFileIcon = (filePath: string, extension?: string): React.ReactNode => {
return <FileTypeIcon filePath={filePath} extension={extension} />;
};
@@ -141,10 +196,6 @@ interface FileRowProps {
canReveal: boolean;
};
downloadFile?: (path: string) => Promise<void>;
contextMenuPath: string | null;
setContextMenuPath: (path: string | null) => void;
rightClickMenuPath: string | null;
setRightClickMenuPath: (path: string | null) => void;
onSelect: (node: FileNode) => void;
onToggle: (path: string) => void;
onRevealPath: (path: string) => void;
@@ -160,10 +211,6 @@ const FileRow: React.FC<FileRowProps> = ({
badge,
permissions,
downloadFile,
contextMenuPath,
setContextMenuPath,
rightClickMenuPath,
setRightClickMenuPath,
onSelect,
onToggle,
onRevealPath,
@@ -173,11 +220,17 @@ const FileRow: React.FC<FileRowProps> = ({
const isDir = node.type === 'directory';
const { canRename, canCreateFile, canCreateFolder, canDelete, canReveal } = permissions;
// Menu open state is local to each row so opening a menu in one row
// never re-renders its siblings. Previously this state lived on the
// parent, which made every FileRow re-render whenever any menu toggled.
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const [rightClickOpen, setRightClickOpen] = React.useState(false);
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete && !canReveal) return;
event?.preventDefault();
setRightClickMenuPath(node.path);
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal, node.path, setRightClickMenuPath]);
setRightClickOpen(true);
}, [canRename, canCreateFile, canCreateFolder, canDelete, canReveal]);
const handleInteraction = React.useCallback(() => {
if (isDir) {
@@ -189,9 +242,9 @@ const FileRow: React.FC<FileRowProps> = ({
const handleMenuButtonClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
setRightClickMenuPath(null);
setContextMenuPath(node.path);
}, [node.path, setContextMenuPath, setRightClickMenuPath]);
setRightClickOpen(false);
setContextMenuOpen(true);
}, []);
const renderMenuItems = ({
Item,
@@ -271,7 +324,7 @@ const FileRow: React.FC<FileRowProps> = ({
}, [node.path, root]);
return (
<ContextMenu open={rightClickMenuPath === node.path} onOpenChange={(open) => setRightClickMenuPath(open ? node.path : null)}>
<ContextMenu open={rightClickOpen} onOpenChange={setRightClickOpen}>
<ContextMenuTrigger render={<div className="group relative flex items-center" onContextMenu={handleContextMenu} />}>
<button
type="button"
@@ -308,8 +361,8 @@ const FileRow: React.FC<FileRowProps> = ({
{(canRename || canCreateFile || canCreateFolder || canDelete || canReveal) && (
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 focus-within:opacity-100 group-hover:opacity-100">
<DropdownMenu
open={contextMenuPath === node.path}
onOpenChange={(open) => setContextMenuPath(open ? node.path : null)}
open={contextMenuOpen}
onOpenChange={setContextMenuOpen}
>
<Tooltip>
<TooltipTrigger asChild>
@@ -330,7 +383,7 @@ const FileRow: React.FC<FileRowProps> = ({
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>{t('sidebarFilesTree.actions.fileMenuTitle')}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" side="bottom" onCloseAutoFocus={() => setContextMenuPath(null)}>
<DropdownMenuContent align="end" side="bottom" onCloseAutoFocus={() => setContextMenuOpen(false)}>
{renderMenuItems({ Item: DropdownMenuItem, Separator: DropdownMenuSeparator })}
</DropdownMenuContent>
</DropdownMenu>
@@ -344,6 +397,23 @@ const FileRow: React.FC<FileRowProps> = ({
);
};
const areFileRowPropsEqual = (prev: FileRowProps, next: FileRowProps): boolean => (
prev.node === next.node
&& prev.root === next.root
&& prev.isExpanded === next.isExpanded
&& prev.isActive === next.isActive
&& prev.status === next.status
&& prev.badge === next.badge
&& prev.permissions === next.permissions
&& prev.downloadFile === next.downloadFile
&& prev.onSelect === next.onSelect
&& prev.onToggle === next.onToggle
&& prev.onRevealPath === next.onRevealPath
&& prev.onOpenDialog === next.onOpenDialog
);
const MemoizedFileRow = React.memo(FileRow, areFileRowPropsEqual);
// --- Main component ---
export const SidebarFilesTree: React.FC = () => {
@@ -368,6 +438,71 @@ export const SidebarFilesTree: React.FC = () => {
const loadedDirsRef = React.useRef<Set<string>>(new Set());
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
// Hydrate the per-root cache on mount or root change. The cache is
// module-scoped so it survives close-and-reopen of the right sidebar;
// expanded paths are already persisted via useFilesViewTabsStore, so
// combining the two means the tree re-paints with cached data instead
// of blanking out and re-listing every directory.
React.useEffect(() => {
if (!root) {
setChildrenByDir({});
setLoadErrorsByDir({});
loadedDirsRef.current = new Set();
return;
}
const cached = touchCache(root);
if (cached) {
// Shallow-clone so the state and cache hold independent references.
// This protects the cache from accidental in-place mutation of state
// (a future contributor could otherwise break the contract silently).
setChildrenByDir({ ...cached.childrenByDir });
setLoadErrorsByDir({ ...cached.loadErrorsByDir });
loadedDirsRef.current = new Set(cached.loadedDirs);
} else {
setChildrenByDir({});
setLoadErrorsByDir({});
loadedDirsRef.current = new Set();
}
}, [root]);
// Mirror local state into the per-root cache. Don't bump touchedAt here:
// writes are frequent and the LRU should reflect user attention, not
// background re-renders.
React.useEffect(() => {
if (!root) return;
const cache = getOrCreateCache(root);
cache.childrenByDir = childrenByDir;
}, [root, childrenByDir]);
React.useEffect(() => {
if (!root) return;
const cache = getOrCreateCache(root);
cache.loadErrorsByDir = loadErrorsByDir;
}, [root, loadErrorsByDir]);
// The ref's contents must be persisted to the cache so a remount (e.g.
// close-and-reopen of the right sidebar) skips re-listing already-known
// directories. Mirror on every change of `root` so the ref → cache sync
// happens once per directory; the ref itself updates synchronously inside
// `loadDirectory` and isn't tracked by React otherwise.
React.useEffect(() => {
if (!root) return;
const cache = getOrCreateCache(root);
cache.loadedDirs = new Set(loadedDirsRef.current);
}, [root, childrenByDir, loadErrorsByDir]);
// Drop the cache entry for this root on unmount when no data was loaded
// (e.g. user opened the tab and immediately switched projects before any
// listDirectory round-trip). A populated entry stays so the next mount
// rehydrates instantly.
React.useEffect(() => () => {
if (!root) return;
const cache = fileTreeCacheByRoot.get(root);
if (cache && cache.loadedDirs.size === 0 && Object.keys(cache.childrenByDir).length === 0) {
dropCacheForRoot(root);
}
}, [root]);
const EMPTY_PATHS: string[] = React.useMemo(() => [], []);
const EMPTY_CONTEXT_TABS: Array<{ mode: string; targetPath: string | null }> = React.useMemo(() => [], []);
const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
@@ -384,10 +519,6 @@ export const SidebarFilesTree: React.FC = () => {
.map((targetPath) => normalizePath(targetPath))
), [contextTabs]);
// Context menu state
const [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
const [rightClickMenuPath, setRightClickMenuPath] = React.useState<string | null>(null);
// Dialog state for CRUD operations
const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null);
const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null);
@@ -440,7 +571,7 @@ export const SidebarFilesTree: React.FC = () => {
return sortNodes(nodes);
}, [showGitignored, showHidden]);
const loadDirectory = React.useCallback(async (dirPath: string) => {
const loadDirectory = React.useCallback(async (dirPath: string, isCancelled?: () => boolean) => {
const normalizedDir = normalizePath(dirPath.trim());
if (!normalizedDir) return;
@@ -461,32 +592,32 @@ export const SidebarFilesTree: React.FC = () => {
isDirectory: entry.isDirectory,
})));
await listPromise
.then((entries) => {
const mapped = mapDirectoryEntries(normalizedDir, entries);
try {
const entries = await listPromise;
if (isCancelled?.()) return;
const mapped = mapDirectoryEntries(normalizedDir, entries);
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir);
setLoadErrorsByDir((prev) => {
if (!prev[normalizedDir]) return prev;
const next = { ...prev };
delete next[normalizedDir];
return next;
});
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error ?? '');
console.error('Failed to load sidebar directory:', error);
setLoadErrorsByDir((prev) => ({
...prev,
[normalizedDir]: message,
}));
})
.finally(() => {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
loadedDirsRef.current = new Set(loadedDirsRef.current);
loadedDirsRef.current.add(normalizedDir);
setLoadErrorsByDir((prev) => {
if (!prev[normalizedDir]) return prev;
const next = { ...prev };
delete next[normalizedDir];
return next;
});
setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped }));
} catch (error) {
if (isCancelled?.()) return;
const message = error instanceof Error ? error.message : String(error ?? '');
console.error('Failed to load sidebar directory:', error);
setLoadErrorsByDir((prev) => ({
...prev,
[normalizedDir]: message,
}));
} finally {
inFlightDirsRef.current = new Set(inFlightDirsRef.current);
inFlightDirsRef.current.delete(normalizedDir);
}
}, [files, mapDirectoryEntries]);
const refreshRoot = React.useCallback(async () => {
@@ -545,12 +676,16 @@ export const SidebarFilesTree: React.FC = () => {
if (toLoad.length === 0) return;
// Load with concurrency limit to avoid API stampede on startup
// Load with concurrency limit to avoid API stampede on startup.
// Each per-dir fetch gets a cancellation predicate so the load stops
// touching state once the effect tears down (e.g. user collapses the
// directory or the directory list changes mid-flight).
let cancelled = false;
const isCancelled = () => cancelled;
void (async () => {
for (let i = 0; i < toLoad.length && !cancelled; i += 3) {
const batch = toLoad.slice(i, i + 3);
await Promise.all(batch.map((dir) => loadDirectory(dir)));
await Promise.all(batch.map((dir) => loadDirectory(dir, isCancelled)));
}
})();
return () => { cancelled = true; };
@@ -612,36 +747,64 @@ export const SidebarFilesTree: React.FC = () => {
}, [currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
// --- Git status helpers (matching FilesView) ---
//
// statusByPath / badgeByDir are precomputed once per gitStatus change so the
// tree render is O(1) per node instead of O(N) per node. Without these
// maps, a deep tree with 200 files and 40 directories would do ~8000
// string comparisons on every render.
const statusByPath = React.useMemo(() => {
const map = new Map<string, FileStatus>();
if (!gitStatus?.files) return map;
for (const file of gitStatus.files) {
if (file.index === 'A' || file.working_dir === '?') {
map.set(file.path, 'git-added');
} else if (file.index === 'D') {
map.set(file.path, 'git-deleted');
} else if (file.index === 'M' || file.working_dir === 'M') {
map.set(file.path, 'git-modified');
}
}
return map;
}, [gitStatus]);
const badgeByDir = React.useMemo(() => {
const map = new Map<string, { modified: number; added: number }>();
if (!gitStatus?.files || !root) return map;
for (const file of gitStatus.files) {
const isModified = file.index === 'M' || file.working_dir === 'M';
const isAdded = file.index === 'A' || file.working_dir === '?';
if (!isModified && !isAdded) continue;
const segments = file.path.split('/');
if (segments.length <= 1) continue;
let currentDir = root;
for (let i = 0; i < segments.length - 1; i++) {
currentDir = `${currentDir}/${segments[i]}`;
let entry = map.get(currentDir);
if (!entry) {
entry = { modified: 0, added: 0 };
map.set(currentDir, entry);
}
if (isModified) entry.modified++;
if (isAdded) entry.added++;
}
}
return map;
}, [gitStatus, root]);
const getFileStatus = React.useCallback((path: string): FileStatus | null => {
if (openContextFilePaths.has(path)) return 'open';
if (gitStatus?.files) {
const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
const file = gitStatus.files.find((f) => f.path === relative);
if (file) {
if (file.index === 'A' || file.working_dir === '?') return 'git-added';
if (file.index === 'D') return 'git-deleted';
if (file.index === 'M' || file.working_dir === 'M') return 'git-modified';
}
}
return null;
}, [openContextFilePaths, gitStatus, root]);
if (statusByPath.size === 0) return null;
const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
return statusByPath.get(relative) ?? null;
}, [openContextFilePaths, statusByPath, root]);
const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => {
if (!gitStatus?.files) return null;
const relativeDir = dirPath.startsWith(root + '/') ? dirPath.slice(root.length + 1) : dirPath;
const prefix = relativeDir ? `${relativeDir}/` : '';
let modified = 0, added = 0;
for (const f of gitStatus.files) {
if (f.path.startsWith(prefix)) {
if (f.index === 'M' || f.working_dir === 'M') modified++;
if (f.index === 'A' || f.working_dir === '?') added++;
}
}
return modified + added > 0 ? { modified, added } : null;
}, [gitStatus, root]);
if (badgeByDir.size === 0) return null;
const entry = badgeByDir.get(dirPath);
if (!entry) return null;
return entry.modified + entry.added > 0 ? entry : null;
}, [badgeByDir]);
// --- File operations ---
@@ -820,7 +983,7 @@ export const SidebarFilesTree: React.FC = () => {
)}
</>
)}
<FileRow
<MemoizedFileRow
node={node}
root={root}
isExpanded={isExpanded}
@@ -829,10 +992,6 @@ export const SidebarFilesTree: React.FC = () => {
badge={isDir ? getFolderBadge(node.path) : undefined}
permissions={fileRowPermissions}
downloadFile={files.downloadFile}
contextMenuPath={contextMenuPath}
setContextMenuPath={setContextMenuPath}
rightClickMenuPath={rightClickMenuPath}
setRightClickMenuPath={setRightClickMenuPath}
onSelect={handleOpenFile}
onToggle={toggleDirectory}
onRevealPath={handleRevealPath}
@@ -50,6 +50,13 @@ import { TodoSendDialog, type TodoSendExecution } from './TodoSendDialog';
const TODO_PANEL_MIN_ITEMS = 5;
const TODO_PANEL_MAX_ITEMS = 15;
// Per-project chain of in-flight saveProjectNotesAndTodos calls. Subsequent
// saves await the previous one so a fast todo toggle or blur that lands
// while the debounced notes save is still on the wire is appended, not
// racing against it. The chain is module-scoped so it survives remounts
// (e.g. when the user switches the right sidebar tab away and back).
const projectSaveChainByProject = new Map<string, Promise<unknown>>();
const getEffectiveItemHeight = (padding: number) => {
const scale = Math.sqrt(padding / 100);
const paddingPx = 12 * scale;
@@ -170,6 +177,7 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
const [contextReloadTick, setContextReloadTick] = React.useState(0);
const notesHydratedRef = React.useRef(false);
const lastSavedNotesRef = React.useRef('');
const notesDebounceTimerRef = React.useRef<number | null>(null);
const todoPanelHeight = useUIStore((state) => state.todoPanelHeight);
const setTodoPanelHeight = useUIStore((state) => state.setTodoPanelHeight);
const notesPanelHeight = useUIStore((state) => state.notesPanelHeight);
@@ -195,14 +203,28 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
if (!projectRef) {
return false;
}
const saved = await saveProjectNotesAndTodos(projectRef, {
notes: nextNotes,
todos: nextTodos,
});
if (!saved) {
toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
const key = projectRef.id;
// Serialize concurrent saves per project: a fast toggle/strike while the
// debounce-driven notes save is in flight no longer races the network.
const previous = projectSaveChainByProject.get(key) ?? Promise.resolve();
const next = previous.catch(() => undefined).then(() =>
saveProjectNotesAndTodos(projectRef, {
notes: nextNotes,
todos: nextTodos,
})
);
projectSaveChainByProject.set(key, next);
try {
const saved = await next;
if (!saved) {
toast.error(t('rightSidebar.contextNotesTodo.toast.saveNotesFailed'));
}
return saved;
} finally {
if (projectSaveChainByProject.get(key) === next) {
projectSaveChainByProject.delete(key);
}
}
return saved;
},
[projectRef, t]
);
@@ -284,7 +306,10 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
}
const targetHeight = getPanelHeightForItems(todos.length, padding);
const minHeight = getEffectiveItemHeight(padding) * TODO_PANEL_MIN_ITEMS;
if (todoPanelHeight < minHeight || todoPanelHeight > targetHeight) {
if (
todoPanelHeight !== targetHeight
&& (todoPanelHeight < minHeight || todoPanelHeight > targetHeight)
) {
setTodoPanelHeight(targetHeight);
}
}, [todos.length, padding, todoPanelHeight, setTodoPanelHeight]);
@@ -325,10 +350,18 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
event.preventDefault();
}, [todoPanelHeight]);
const cancelNotesDebounce = React.useCallback(() => {
if (notesDebounceTimerRef.current !== null) {
window.clearTimeout(notesDebounceTimerRef.current);
notesDebounceTimerRef.current = null;
}
}, []);
const handleNotesBlur = React.useCallback(() => {
cancelNotesDebounce();
lastSavedNotesRef.current = notes;
void persistProjectData(notes, todos);
}, [notes, persistProjectData, todos]);
}, [cancelNotesDebounce, notes, persistProjectData, todos]);
React.useEffect(() => {
if (!projectRef || !notesHydratedRef.current) {
@@ -339,15 +372,18 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
return;
}
const timer = window.setTimeout(() => {
notesDebounceTimerRef.current = window.setTimeout(() => {
notesDebounceTimerRef.current = null;
lastSavedNotesRef.current = notes;
void persistProjectData(notes, todos);
}, 400);
return () => {
window.clearTimeout(timer);
cancelNotesDebounce();
};
}, [notes, persistProjectData, projectRef, todos]);
}, [cancelNotesDebounce, notes, persistProjectData, projectRef, todos]);
React.useEffect(() => () => cancelNotesDebounce(), [cancelNotesDebounce]);
const handleAddTodo = React.useCallback(() => {
const trimmed = newTodoText.trim();
+119 -151
View File
@@ -6,6 +6,7 @@ import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/t
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useShallow } from 'zustand/react/shallow';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useGitmojiList } from '@/hooks/useGitmojiList';
import { copyTextToClipboard } from '@/lib/clipboard';
import {
useGitStore,
@@ -93,19 +94,8 @@ type GitmojiEntry = {
description: string;
};
type GitmojiCachePayload = {
gitmojis: GitmojiEntry[];
fetchedAt: number;
version: string;
};
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';
const KEYWORD_MAP: Record<string, string> = {
'feat': ':sparkles:',
@@ -147,50 +137,6 @@ const KEYWORD_MAP: Record<string, string> = {
'initial': ':tada:',
};
const isGitmojiEntry = (value: unknown): value is GitmojiEntry => {
if (!value || typeof value !== 'object') return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.emoji === 'string' &&
typeof candidate.code === 'string' &&
typeof candidate.description === 'string'
);
};
const readGitmojiCache = (): GitmojiCachePayload | null => {
if (typeof window === 'undefined') return null;
try {
const raw = localStorage.getItem(GITMOJI_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<GitmojiCachePayload>;
if (!parsed || parsed.version !== GITMOJI_CACHE_VERSION || typeof parsed.fetchedAt !== 'number') {
return null;
}
if (!Array.isArray(parsed.gitmojis)) return null;
const gitmojis = parsed.gitmojis.filter(isGitmojiEntry);
return { gitmojis, fetchedAt: parsed.fetchedAt, version: parsed.version };
} catch {
return null;
}
};
const writeGitmojiCache = (gitmojis: GitmojiEntry[]) => {
if (typeof window === 'undefined') return;
try {
const payload: GitmojiCachePayload = {
gitmojis,
fetchedAt: Date.now(),
version: GITMOJI_CACHE_VERSION,
};
localStorage.setItem(GITMOJI_CACHE_KEY, JSON.stringify(payload));
} catch {
return;
}
};
const isGitmojiCacheFresh = (payload: GitmojiCachePayload) =>
Date.now() - payload.fetchedAt < GITMOJI_CACHE_TTL_MS;
const matchGitmojiFromSubject = (subject: string, gitmojis: GitmojiEntry[]): GitmojiEntry | null => {
const lowerSubject = subject.toLowerCase();
@@ -217,8 +163,23 @@ const matchGitmojiFromSubject = (subject: string, gitmojis: GitmojiEntry[]): Git
return null;
};
const GIT_VIEW_SNAPSHOTS_CAP = 20;
const gitViewSnapshots = new Map<string, GitViewSnapshot>();
const rememberSnapshot = (key: string, snapshot: GitViewSnapshot) => {
// Touch-on-write LRU: deleting before re-inserting promotes the key to
// the Map's insertion order, so the oldest key falls off the end.
gitViewSnapshots.delete(key);
gitViewSnapshots.set(key, snapshot);
if (gitViewSnapshots.size > GIT_VIEW_SNAPSHOTS_CAP) {
const oldest = gitViewSnapshots.keys().next().value;
if (oldest !== undefined) {
gitViewSnapshots.delete(oldest);
}
}
};
const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
@@ -233,7 +194,11 @@ const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => {
return Boolean(workingStatus || indexStatus === '?');
};
export const GitView: React.FC = () => {
type GitViewProps = {
isActive: boolean;
};
export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
const { t } = useI18n();
const { git } = useRuntimeAPIs();
const currentDirectory = useEffectiveDirectory();
@@ -308,26 +273,44 @@ export const GitView: React.FC = () => {
const currentIdentity = useGitIdentity(currentDirectory ?? null);
const isLoading = useGitLoadingStatus(currentDirectory ?? null);
const isLogLoading = useGitLoadingLog(currentDirectory ?? null);
const setActiveDirectory = useGitStore((state) => state.setActiveDirectory);
const fetchAll = useGitStore((state) => state.fetchAll);
const ensureAll = useGitStore((state) => state.ensureAll);
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const fetchLog = useGitStore((state) => state.fetchLog);
const setLogMaxCount = useGitStore((state) => state.setLogMaxCount);
const fetchIdentity = useGitStore((state) => state.fetchIdentity);
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
const moveStatusPathsOptimistically = useGitStore((state) => state.moveStatusPathsOptimistically);
const restoreStatus = useGitStore((state) => state.restoreStatus);
const bumpIndexRevision = useGitStore((state) => state.bumpIndexRevision);
const {
setActiveDirectory,
fetchAll,
ensureAll,
fetchStatus,
fetchBranches,
fetchLog,
setLogMaxCount,
fetchIdentity,
prefetchDiffs,
moveStatusPathsOptimistically,
restoreStatus,
bumpIndexRevision,
} = useGitStore(useShallow((state) => ({
setActiveDirectory: state.setActiveDirectory,
fetchAll: state.fetchAll,
ensureAll: state.ensureAll,
fetchStatus: state.fetchStatus,
fetchBranches: state.fetchBranches,
fetchLog: state.fetchLog,
setLogMaxCount: state.setLogMaxCount,
fetchIdentity: state.fetchIdentity,
prefetchDiffs: state.prefetchDiffs,
moveStatusPathsOptimistically: state.moveStatusPathsOptimistically,
restoreStatus: state.restoreStatus,
bumpIndexRevision: state.bumpIndexRevision,
})));
const isMobile = useUIStore((state) => state.isMobile);
const openContextDiff = useUIStore((state) => state.openContextDiff);
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen);
const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null);
const gitReconcileTimeoutRef = React.useRef<number | null>(null);
const gitMutationFlushTimeoutRef = React.useRef<number | null>(null);
const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null);
const mountedRef = React.useRef(true);
React.useEffect(() => () => { mountedRef.current = false; }, []);
const clearScheduledGitReconcile = React.useCallback(() => {
if (gitReconcileTimeoutRef.current === null) {
@@ -429,6 +412,7 @@ export const GitView: React.FC = () => {
React.useEffect(() => clearScheduledGitMutationFlush, [clearScheduledGitMutationFlush]);
React.useEffect(() => {
if (!isActive) return;
if (!currentDirectory) {
setWorktreeBootstrapStatus(null);
setIsWaitingForGitRefreshAfterBootstrap(false);
@@ -465,7 +449,7 @@ export const GitView: React.FC = () => {
window.clearTimeout(timeoutId);
}
};
}, [currentDirectory]);
}, [isActive, currentDirectory]);
React.useEffect(() => {
const previous = previousBootstrapStatusRef.current;
@@ -506,6 +490,7 @@ export const GitView: React.FC = () => {
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
const { gitmojis: gitmojiEmojis } = useGitmojiList(settingsGitmojiEnabled);
React.useEffect(() => {
const projectRoot = authoritativeProjectRoot || worktreeMetadata?.projectDirectory;
@@ -550,12 +535,14 @@ export const GitView: React.FC = () => {
const beginIdentityApply = React.useCallback(() => {
identityApplyCountRef.current += 1;
setIsSettingIdentity(true);
if (mountedRef.current) {
setIsSettingIdentity(true);
}
}, []);
const endIdentityApply = React.useCallback(() => {
identityApplyCountRef.current = Math.max(0, identityApplyCountRef.current - 1);
if (identityApplyCountRef.current === 0) {
if (mountedRef.current && identityApplyCountRef.current === 0) {
setIsSettingIdentity(false);
}
}, []);
@@ -625,7 +612,6 @@ export const GitView: React.FC = () => {
const [loadingCommitHashes, setLoadingCommitHashes] = React.useState<Set<string>>(new Set());
const [historyBranchDivider, setHistoryBranchDivider] = React.useState<HistoryBranchDivider>(null);
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
const [gitLogDialogMode, setGitLogDialogMode] = React.useState<GitLogDialogMode | null>(null);
@@ -749,6 +735,8 @@ export const GitView: React.FC = () => {
if (hashesToLoad.length === 0) return;
let cancelled = false;
setLoadingCommitHashes((prev) => {
const next = new Set(prev);
for (const hash of hashesToLoad) {
@@ -757,29 +745,42 @@ export const GitView: React.FC = () => {
return next;
});
for (const hash of hashesToLoad) {
git
.getCommitFiles(currentDirectory, hash)
.then((response) => {
setCommitFilesMap((prev) => new Map(prev).set(hash, response.files));
})
.catch((error) => {
console.error('Failed to fetch commit files:', error);
setCommitFilesMap((prev) => new Map(prev).set(hash, []));
})
.finally(() => {
setLoadingCommitHashes((prev) => {
const next = new Set(prev);
next.delete(hash);
return next;
});
});
}
void Promise.all(
hashesToLoad.map((hash) =>
git
.getCommitFiles(currentDirectory, hash)
.then((response) => ({ hash, files: response.files }))
.catch((error) => {
console.error('Failed to fetch commit files:', error);
return { hash, files: [] as CommitFileEntry[] };
})
)
).then((results) => {
if (cancelled) return;
setCommitFilesMap((prev) => {
const next = new Map(prev);
for (const { hash, files } of results) {
next.set(hash, files);
}
return next;
});
setLoadingCommitHashes((prev) => {
const next = new Set(prev);
for (const { hash } of results) {
next.delete(hash);
}
return next;
});
});
return () => {
cancelled = true;
};
}, [expandedCommitHashes, currentDirectory, git, commitFilesMap, loadingCommitHashes]);
React.useEffect(() => {
if (!currentDirectory) return;
gitViewSnapshots.set(currentDirectory, {
rememberSnapshot(currentDirectory, {
directory: currentDirectory,
commitMessage,
generatedHighlights,
@@ -787,18 +788,25 @@ export const GitView: React.FC = () => {
}, [commitMessage, currentDirectory, generatedHighlights]);
React.useEffect(() => {
if (!isActive) return;
loadProfiles();
loadGlobalIdentity();
loadDefaultGitIdentityId();
}, [loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId]);
}, [isActive, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId]);
React.useEffect(() => {
if (!isActive) return;
if (!currentDirectory || !git?.getRemoteUrl) {
setRemoteUrl(null);
return;
}
git.getRemoteUrl(currentDirectory).then(setRemoteUrl).catch(() => setRemoteUrl(null));
}, [currentDirectory, git]);
let cancelled = false;
git
.getRemoteUrl(currentDirectory)
.then((url) => { if (!cancelled) setRemoteUrl(url); })
.catch(() => { if (!cancelled) setRemoteUrl(null); });
return () => { cancelled = true; };
}, [isActive, currentDirectory, git]);
const refreshRemotes = React.useCallback(async () => {
if (!currentDirectory || !git?.getRemotes) {
@@ -807,68 +815,31 @@ export const GitView: React.FC = () => {
}
try {
const remoteList = await git.getRemotes(currentDirectory);
setRemotes(remoteList);
if (mountedRef.current) {
setRemotes(remoteList);
}
} catch {
setRemotes([]);
if (mountedRef.current) {
setRemotes([]);
}
}
}, [currentDirectory, git]);
React.useEffect(() => {
if (!isActive) return;
void refreshRemotes();
}, [refreshRemotes]);
React.useEffect(() => {
if (!settingsGitmojiEnabled) {
setGitmojiEmojis([]);
return;
}
let cancelled = false;
const cached = readGitmojiCache();
if (cached) {
setGitmojiEmojis(cached.gitmojis);
if (isGitmojiCacheFresh(cached)) {
return () => {
cancelled = true;
};
}
}
const loadGitmojis = async () => {
try {
const response = await fetch(GITMOJI_SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to load gitmojis: ${response.statusText}`);
}
const payload = (await response.json()) as { gitmojis?: GitmojiEntry[] };
const gitmojis = Array.isArray(payload.gitmojis) ? payload.gitmojis.filter(isGitmojiEntry) : [];
if (!cancelled) {
setGitmojiEmojis(gitmojis);
writeGitmojiCache(gitmojis);
}
} catch (error) {
if (!cancelled) {
console.warn('Failed to load gitmoji list:', error);
}
}
};
void loadGitmojis();
return () => {
cancelled = true;
};
}, [settingsGitmojiEnabled]);
}, [isActive, refreshRemotes]);
React.useEffect(() => {
if (!isActive) return;
if (currentDirectory) {
setActiveDirectory(currentDirectory);
void ensureAll(currentDirectory, git);
}
}, [currentDirectory, setActiveDirectory, ensureAll, git]);
}, [isActive, currentDirectory, setActiveDirectory, ensureAll, git]);
React.useEffect(() => {
if (!isActive) return;
if (!currentDirectory) {
return;
}
@@ -879,7 +850,7 @@ export const GitView: React.FC = () => {
}
void fetchStatus(currentDirectory, git);
});
}, [currentDirectory, fetchStatus, git]);
}, [isActive, currentDirectory, fetchStatus, git]);
const refreshStatusAndBranches = React.useCallback(
async (showErrors = true) => {
@@ -912,6 +883,7 @@ export const GitView: React.FC = () => {
}, [currentDirectory, git, fetchIdentity]);
React.useEffect(() => {
if (!isActive) return;
if (!currentDirectory) return;
if (!git?.hasLocalIdentity) return;
if (isGitRepo !== true) return;
@@ -948,18 +920,14 @@ export const GitView: React.FC = () => {
return () => {
cancelled = true;
};
}, [beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]);
}, [isActive, beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]);
const changeEntries = React.useMemo(() => {
if (!status) return [];
const files = status.files ?? [];
const unique = new Map<string, (typeof files)[number]>();
for (const file of files) {
unique.set(file.path, file);
}
return Array.from(unique.values()).sort((a, b) => a.path.localeCompare(b.path));
// GitStatus.files is already unique by `path` per the server contract;
// a defensive dedup pass would only mask real upstream bugs.
return [...files].sort((a, b) => a.path.localeCompare(b.path));
}, [status]);
const stagedChangeEntries = React.useMemo(
+170
View File
@@ -0,0 +1,170 @@
import React from 'react';
export type GitmojiEntry = {
emoji: string;
code: string;
description: string;
};
type GitmojiCachePayload = {
gitmojis: GitmojiEntry[];
fetchedAt: number;
version: string;
};
const GITMOJI_CACHE_KEY = 'gitmojiCache';
const GITMOJI_CACHE_TTL_MS = 1000 * 60 * 60 * 24 * 7;
const GITMOJI_CACHE_VERSION = '1';
const GITMOJI_SOURCE_URL =
'https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json';
const isGitmojiEntry = (value: unknown): value is GitmojiEntry => {
if (!value || typeof value !== 'object') return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.emoji === 'string' &&
typeof candidate.code === 'string' &&
typeof candidate.description === 'string'
);
};
const readGitmojiCache = (): GitmojiCachePayload | null => {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(GITMOJI_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<GitmojiCachePayload>;
if (!parsed || parsed.version !== GITMOJI_CACHE_VERSION || typeof parsed.fetchedAt !== 'number') {
return null;
}
if (!Array.isArray(parsed.gitmojis)) return null;
const gitmojis = parsed.gitmojis.filter(isGitmojiEntry);
return { gitmojis, fetchedAt: parsed.fetchedAt, version: parsed.version };
} catch {
return null;
}
};
const writeGitmojiCache = (gitmojis: GitmojiEntry[]) => {
if (typeof window === 'undefined') return;
try {
const payload: GitmojiCachePayload = {
gitmojis,
fetchedAt: Date.now(),
version: GITMOJI_CACHE_VERSION,
};
window.localStorage.setItem(GITMOJI_CACHE_KEY, JSON.stringify(payload));
} catch {
return;
}
};
const isGitmojiCacheFresh = (payload: GitmojiCachePayload): boolean =>
Date.now() - payload.fetchedAt < GITMOJI_CACHE_TTL_MS;
// Module-level inflight + subscribers so concurrent hook instances dedupe the
// same fetch. Subscribers receive the resolved list via setState on each hook
// instance, so every consumer converges on the same data.
let inflightFetch: Promise<GitmojiEntry[]> | null = null;
const subscribers = new Set<(entries: GitmojiEntry[]) => void>();
const fetchGitmojiList = async (): Promise<GitmojiEntry[]> => {
if (inflightFetch) return inflightFetch;
const promise = (async () => {
try {
const response = await fetch(GITMOJI_SOURCE_URL);
if (!response.ok) {
throw new Error(`Failed to load gitmojis: ${response.statusText}`);
}
const payload = (await response.json()) as { gitmojis?: GitmojiEntry[] };
const gitmojis = Array.isArray(payload.gitmojis)
? payload.gitmojis.filter(isGitmojiEntry)
: [];
writeGitmojiCache(gitmojis);
subscribers.forEach((callback) => callback(gitmojis));
return gitmojis;
} catch (error) {
console.warn('Failed to load gitmoji list:', error);
const empty: GitmojiEntry[] = [];
subscribers.forEach((callback) => callback(empty));
return empty;
} finally {
inflightFetch = null;
}
})();
inflightFetch = promise;
return promise;
};
export type UseGitmojiListResult = {
gitmojis: GitmojiEntry[];
isLoading: boolean;
ensureLoaded: () => Promise<GitmojiEntry[]>;
};
/**
* Returns the gitmoji emoji catalog, hydrated from a local cache and
* stale-while-revalidated from the upstream JSON.
*
* - When `enabled` is false, the hook returns an empty list and skips all IO.
* - The first call hydrates synchronously from localStorage (no network
* round-trip on warm starts).
* - If the cache is missing or stale, a single background fetch is fired and
* deduped across concurrent hook instances.
* - Callers can invoke `ensureLoaded()` to await the next settled value when
* they need the list synchronously (e.g. auto-suggest on commit subject).
*/
export const useGitmojiList = (enabled: boolean): UseGitmojiListResult => {
const [gitmojis, setGitmojis] = React.useState<GitmojiEntry[]>(() => {
if (!enabled) return [];
return readGitmojiCache()?.gitmojis ?? [];
});
const [isLoading, setIsLoading] = React.useState(false);
const enabledRef = React.useRef(enabled);
enabledRef.current = enabled;
React.useEffect(() => {
if (!enabled) {
subscribers.delete(setGitmojis);
setGitmojis([]);
setIsLoading(false);
return;
}
const cached = readGitmojiCache();
if (cached) {
setGitmojis(cached.gitmojis);
if (isGitmojiCacheFresh(cached)) {
return;
}
}
subscribers.add(setGitmojis);
setIsLoading(true);
let cancelled = false;
void fetchGitmojiList().finally(() => {
if (!cancelled && enabledRef.current) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
subscribers.delete(setGitmojis);
};
}, [enabled]);
const ensureLoaded = React.useCallback(async (): Promise<GitmojiEntry[]> => {
if (!enabledRef.current) return [];
if (gitmojis.length > 0) return gitmojis;
const cached = readGitmojiCache();
if (cached && isGitmojiCacheFresh(cached)) {
return cached.gitmojis;
}
return fetchGitmojiList();
}, [gitmojis]);
return { gitmojis, isLoading, ensureLoaded };
};
+14 -18
View File
@@ -111,7 +111,8 @@ const CONTEXT_PANEL_MAX_WIDTH = 1400;
const CONTEXT_PANEL_MAX_TABS = 12;
const CONTEXT_PANEL_MAX_LABEL_LENGTH = 120;
const LEFT_SIDEBAR_MIN_WIDTH = 280;
const RIGHT_SIDEBAR_MIN_WIDTH = 360;
export const RIGHT_SIDEBAR_MIN_WIDTH = 360;
export const RIGHT_SIDEBAR_MAX_WIDTH = 860;
const activeMainTabByRuntime = new Map<string, MainTab>();
const runtimeMemoryKey = (value?: string | null): string => {
@@ -979,29 +980,24 @@ export const useUIStore = create<UIStore>()(
setRightSidebarOpen: (open) => {
set((state) => {
if (state.isRightSidebarOpen === open) {
if (!open) {
return state;
}
if (!state.hasManuallyResizedRightSidebar && state.rightSidebarWidth !== RIGHT_SIDEBAR_MIN_WIDTH) {
return {
isRightSidebarOpen: open,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
};
}
return state;
}
if (open && !state.hasManuallyResizedRightSidebar) {
return {
isRightSidebarOpen: open,
rightSidebarWidth: RIGHT_SIDEBAR_MIN_WIDTH,
};
}
return { isRightSidebarOpen: open };
const shouldResetWidth = open
&& !state.hasManuallyResizedRightSidebar
&& state.rightSidebarWidth !== RIGHT_SIDEBAR_MIN_WIDTH;
return {
isRightSidebarOpen: open,
rightSidebarWidth: shouldResetWidth ? RIGHT_SIDEBAR_MIN_WIDTH : state.rightSidebarWidth,
};
});
},
setRightSidebarWidth: (width) => {
set({ rightSidebarWidth: width, hasManuallyResizedRightSidebar: true });
const clamped = Math.min(
RIGHT_SIDEBAR_MAX_WIDTH,
Math.max(RIGHT_SIDEBAR_MIN_WIDTH, width)
);
set({ rightSidebarWidth: clamped, hasManuallyResizedRightSidebar: true });
},
setRightSidebarTab: (tab) => {