* 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>
212 lines
7.1 KiB
TypeScript
212 lines
7.1 KiB
TypeScript
import React from 'react';
|
|
|
|
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
|
import { ProjectNotesTodoPanel } from '@/components/session/ProjectNotesTodoPanel';
|
|
import { GitView } from '@/components/views/GitView';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { useGitStore } from '@/stores/useGitStore';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
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'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,
|
|
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 (!shouldPoll || !directory || !git) return;
|
|
|
|
void ensureStatus(directory, git);
|
|
|
|
const POLL_INTERVAL = 10_000;
|
|
const id = window.setInterval(() => {
|
|
if (!isBrowserActive()) return;
|
|
void ensureStatus(directory, git);
|
|
}, POLL_INTERVAL);
|
|
|
|
return () => window.clearInterval(id);
|
|
}, [shouldPoll, directory, git, ensureStatus]);
|
|
}
|
|
|
|
export const ProjectContextPanel: React.FC = () => {
|
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
|
const projects = useProjectsStore((state) => state.projects);
|
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
|
const gitDirectories = useGitStore((state) => state.directories);
|
|
|
|
const activeProject = React.useMemo(() => {
|
|
if (activeProjectId) {
|
|
return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null;
|
|
}
|
|
return projects[0] ?? null;
|
|
}, [activeProjectId, projects]);
|
|
|
|
const projectRef = React.useMemo(() => {
|
|
if (!activeProject) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: activeProject.id,
|
|
path: activeProject.path,
|
|
};
|
|
}, [activeProject]);
|
|
|
|
const projectLabel = React.useMemo(() => {
|
|
if (!activeProject) {
|
|
return null;
|
|
}
|
|
return activeProject.label?.trim()
|
|
|| formatDirectoryName(activeProject.path, homeDirectory)
|
|
|| activeProject.path;
|
|
}, [activeProject, homeDirectory]);
|
|
|
|
const canCreateWorktree = React.useMemo(() => {
|
|
if (!activeProject) {
|
|
return false;
|
|
}
|
|
return gitDirectories.get(activeProject.path)?.isGitRepo === true;
|
|
}, [activeProject, gitDirectories]);
|
|
|
|
return (
|
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
|
<ProjectNotesTodoPanel
|
|
projectRef={projectRef}
|
|
projectLabel={projectLabel}
|
|
canCreateWorktree={canCreateWorktree}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const RightSidebarTabs: React.FC = () => {
|
|
const { t } = useI18n();
|
|
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, 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(() => [
|
|
{
|
|
id: 'git',
|
|
label: t('layout.rightSidebar.git'),
|
|
icon: <Icon name="git-branch" className="h-3.5 w-3.5" />,
|
|
},
|
|
{
|
|
id: 'files',
|
|
label: t('layout.rightSidebar.files'),
|
|
icon: <Icon name="folder-3" className="h-3.5 w-3.5" />,
|
|
},
|
|
{
|
|
id: 'context',
|
|
label: t('layout.rightSidebar.context'),
|
|
icon: <Icon name="file-list-2" className="h-3.5 w-3.5" />,
|
|
},
|
|
], [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={visibleTabItems}
|
|
activeId={rightSidebarTab}
|
|
onSelect={handleTabSelect}
|
|
layoutMode="fit"
|
|
variant="active-pill"
|
|
className="h-full"
|
|
/>
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
<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>
|
|
);
|
|
};
|