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:
committed by
GitHub
co-authored by
Leonid Skorobogatyy
Bohdan Triapitsyn
parent
e982bd9388
commit
9199798a14
@@ -0,0 +1,540 @@
|
||||
# Chat Session Performance Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Reduce render cost and history-load latency when switching between chat sessions
|
||||
in `packages/ui/src/components/chat/`, especially on Windows desktop with
|
||||
projects that have a long-running chat history (50–500 messages per session,
|
||||
tool-heavy workloads where each tool call returns 10–200 KB of output).
|
||||
|
||||
The plan is **strictly behavior-preserving**: every change keeps the visible
|
||||
behavior 1:1 with the current build. Differences are only in the cost of doing
|
||||
the work, not in the UX, layout, limits, persistence, or order of operations.
|
||||
|
||||
The two scenarios this targets:
|
||||
|
||||
- Switching between sessions in the sidebar (especially in/out of a session
|
||||
with a long history) — the main window currently blocks the main thread
|
||||
for 200–500 ms on first paint while `JSON.parse` + `materializeSessionSnapshots`
|
||||
+ `projectTurnRecords` run synchronously over the full initial page.
|
||||
- Returning to a session you visited a few minutes ago — the in-memory LRU
|
||||
(`SESSION_MESSAGE_RECORDS_CACHE_MAX = 40` desktop) helps, but the second
|
||||
visit to a session outside that window still pays the full network + parse
|
||||
cost.
|
||||
|
||||
A secondary goal is to avoid hammering the OpenCode server with large
|
||||
single-shot fetches; we want smaller, more frequent, deduped requests.
|
||||
|
||||
## Upstream context
|
||||
|
||||
Before implementing, I scanned open/closed PRs for overlap and lessons:
|
||||
|
||||
- **#1651 — "perf: migrate chat rendering to virtua"** (`b920fd6f`, MERGED).
|
||||
Already moved chat history to `virtua` virtualization with `bufferSize: 900`
|
||||
and `MESSAGE_LIST_VIRTUALIZE_THRESHOLD = 5`, plus deferred Prism highlight,
|
||||
mermaid/file annotation, table listeners, stable plugin lists. This plan
|
||||
builds on top of that.
|
||||
- **#1650 — "perf: instant startup via cache hydration + decoupled readiness"**
|
||||
(`c62f0d1c`, MERGED). Instant startup + decoupled readiness — orthogonal.
|
||||
- **#1503 — "fix(chat): do not force scroll in thinking block during streaming"**
|
||||
(OPEN). Scroll behaviour during streaming — not in our hot path.
|
||||
- **#1584 — "feat: improve long user message collapse, scrolling, and navigation"**
|
||||
(DRAFT). Touches long user messages; no direct conflict with this plan.
|
||||
- **#1621 — "feat: comprehensive scrolling and navigation improvements"**
|
||||
(DRAFT). Broad scroll/navigation PR; no direct conflict.
|
||||
- **#1282 — "Optimize large-session sidebar trees, switching UX, and chat
|
||||
scroll restore stability"** (CLOSED by maintainer). Introduced session-
|
||||
switching instability, selection/chat desync, optimistic transitions.
|
||||
Lesson: keep this plan focused on render-cost reduction and stable memoization;
|
||||
do NOT add optimistic transitions, backend search endpoints, scroll-
|
||||
restoration loops, or pagination on top of an unstable base.
|
||||
- **#1448 — "feat: implement unified multi-server sidebar"** (DRAFT). Rewrites
|
||||
large parts of `SessionSidebar`. Independent of this plan.
|
||||
|
||||
There is currently NO open PR targeting the same render-cost or history-load
|
||||
problems for the chat panel. We can proceed without coordination.
|
||||
|
||||
## Current Architecture Notes
|
||||
|
||||
### Data flow on session switch
|
||||
|
||||
1. `ChatContainer` subscribes to `currentSessionId` from `useSessionUIStore`.
|
||||
2. `useSessionMessageRecords(currentSessionId, directory)` reads from the
|
||||
directory-scoped sync store via `React.useSyncExternalStore`. Snapshot
|
||||
cache: `SESSION_MESSAGE_RECORDS_CACHE_MAX = 40` desktop, `4` vscode/mobile.
|
||||
3. `useChatTimelineController.turnWindowModel` is rebuilt via
|
||||
`buildTurnWindowModel(messages)` on first open of a session; cached per
|
||||
sessionId in module-level `turnModelCache` (max 30 desktop, 4 constrained).
|
||||
4. `MessageList.baseDisplayMessages` reverses + dedupes + normalizes each
|
||||
message via `getNormalizedMessageForDisplay` (per-message `WeakMap` cache).
|
||||
5. `useTurnRecords` projects turn records via `projectTurnRecords` (O(N) over
|
||||
messages + per-turn work for summary/activity/diffStats/changedFiles).
|
||||
`previousProjectionRef` stabilizes across re-renders, but there is no
|
||||
module-level projection cache — returning to a session after the component
|
||||
unmounts pays the full projection cost again.
|
||||
6. `useChatTimelineController` runs the `useLayoutEffect` on `[sessionId]`
|
||||
that resets `isLoadingOlder`, `pendingRevealWork`, `activeTurnId`.
|
||||
|
||||
### Render hot path (current cost map)
|
||||
|
||||
- `ChatContainer` (990 lines) subscribes to the full directory `permission`
|
||||
and `question` records via `useDirectorySync((s) => s.permission ?? {})` and
|
||||
`useDirectorySync((s) => s.question ?? {})` (`ChatContainer.tsx:436-443`).
|
||||
Any `permission.asked` or `question.asked` event for any session in the
|
||||
same directory re-renders `ChatContainer` and re-runs the `permissionsMap` /
|
||||
`questionsMap` / `scopedSessionIds` / `sessionPermissions` / `sessionQuestions`
|
||||
/ `sessionIsWorking` chain. Same for `useSessions(directory)` — every
|
||||
`session.updated` event for any session in the directory re-renders the
|
||||
container.
|
||||
- `MessageList.reviewTransferDirection` (`MessageList.tsx:1151`) does
|
||||
`useGlobalSessionsStore((state) => { const currentSession = state.activeSessions.find(...) })`
|
||||
— runs `find` + `some` over the entire global active list on every global
|
||||
store change.
|
||||
- `MessageList` already has a `useStableEvent(getAnimationHandlers)`,
|
||||
`useStableEvent(scrollToBottom)`, and a module-level `timelineCache` for
|
||||
the virtua cache snapshot — those are the right pattern, just not applied
|
||||
uniformly.
|
||||
- `ChatMessage` (1182 lines) is `React.memo`-wrapped with
|
||||
`areRenderRelevantMessagesEqual` plus `areRelevantTurnGroupingContextsEqual`.
|
||||
`MessageBody` (2121 lines) is NOT `React.memo`-wrapped but is only
|
||||
rendered through `MessageRow` → `ChatMessage` and only re-runs when
|
||||
`areRenderRelevantMessagesEqual` returns false.
|
||||
|
||||
### The 1.5 MB problem
|
||||
|
||||
The OpenCode server (separate repo, `anomalyco/opencode`) responds to
|
||||
`GET /api/session/{sessionID}/message` with
|
||||
`Array<{ info: Message, parts: Part[] }>` (the v1 wire shape preserved in
|
||||
the v2 SDK). On a typical tool-heavy session of 150 messages, this array
|
||||
serializes to ~1.5 MB of JSON. The bulk is `ToolStateCompleted.output: string`
|
||||
(confirmed in `node_modules/.bun/@opencode-ai+sdk@1.17.7/.../types.gen.d.ts:357-373`).
|
||||
|
||||
The OpenChamber web server (`packages/web/server/index.js:1115`) installs
|
||||
Express `compression()` middleware BEFORE the OpenCode proxy
|
||||
(`packages/web/server/lib/opencode/proxy.js`), so the response IS compressed
|
||||
on the way out (5x reduction on typical JSON, so ~300 KB on the wire). The
|
||||
proxy explicitly sets `accept-encoding: identity` (`proxy.js:629`) which is
|
||||
a no-op against the upstream OpenCode server (it has no compression
|
||||
middleware — `server.ts` uses raw `@effect/platform-node` NodeHttpServer).
|
||||
|
||||
So the **wire cost is ~300 KB after gzip**, not 1.5 MB. The real cost is the
|
||||
**client-side main-thread blocking**:
|
||||
|
||||
- `JSON.parse` on 1.5 MB of structured data: ~50–200 ms in V8.
|
||||
- `materializeSessionSnapshots`: O(N) sort/filter, ~10–20 ms for 150.
|
||||
- `projectTurnRecords`: O(N) with per-turn work (summary/activity/diffStats/
|
||||
changedFiles/indexes), ~30–100 ms for 150.
|
||||
- `useChatTimelineController.useLayoutEffect` re-resolves pending scroll
|
||||
requests and updates `historySignals` — fast but runs synchronously.
|
||||
|
||||
Total main-thread block on first paint: **200–500 ms**. The user-perceived
|
||||
"long load" is mostly this.
|
||||
|
||||
### Limit constants (current)
|
||||
|
||||
| Constant | Value | Location | Purpose |
|
||||
| ------------------------------------- | --------------------------------- | --------------------- | --------------------------------------- |
|
||||
| `INITIAL_MESSAGE_PAGE_SIZE` | 150 (desktop), 30 (vscode/mobile) | `use-sync.ts:25-27` | First page on session switch |
|
||||
| `HISTORY_MESSAGE_PAGE_SIZE` | 200 | `use-sync.ts:28` | Scroll-up pagination |
|
||||
| `MESSAGE_REFETCH_LIMIT` | 200 | `session-actions.ts:27` | Refetch after revert/abort |
|
||||
| `RECONNECT_MESSAGE_LIMIT` | 30 | `sync-context.tsx:193` | Reconnect bootstrap |
|
||||
| `SESSION_MATERIALIZATION_MESSAGE_LIMIT` | 30 | `sync-context.tsx:194` | Materialize on `message.updated` recovery |
|
||||
| `DEFAULT_MESSAGE_LIMIT` | 200 | `sessionTypes.ts:78` | Declarative ceiling; not enforced |
|
||||
|
||||
For 99% of sessions, users read the latest 30–50 messages and rarely scroll
|
||||
beyond. The 150-initial / 200-history combination fetches ~3x more than the
|
||||
visible window on first paint.
|
||||
|
||||
### Already optimized (do not touch)
|
||||
|
||||
- `MessageList` virtualization with `virtua` at threshold 5, bufferSize 900
|
||||
(PR #1651).
|
||||
- `MessageRow` / `ChatMessage` / `TurnBlock` `React.memo` with custom
|
||||
comparators.
|
||||
- `getNormalizedMessageForDisplay` per-message `WeakMap` cache.
|
||||
- `MarkdownRenderer` lazy-loaded via `lazyWithChunkRecovery`; Prism highlight
|
||||
and mermaid deferred (PR #1651).
|
||||
- `expandedToolsStateCache` and `collapsedToolsStateCache` module-level LRU
|
||||
bounded at 4000 (`ChatMessage.tsx:39-95`).
|
||||
- `aggregateLiveSessions` / `aggregateLiveSessionStatuses` bail via
|
||||
`areSessionListsEquivalent` and `areStatusMapsEquivalent`; consumed through
|
||||
`useLiveSyncSelector`.
|
||||
- `useStickyProjectHeaders` IntersectionObserver.
|
||||
- Targeted field cloning in `handleDirectoryEvent` (sync/DOCUMENTATION.md).
|
||||
- `turnModelCache` for `buildTurnWindowModel` (30 desktop, 4 vscode/mobile).
|
||||
- `timelineCache` for virtua snapshot (max 16 sessions).
|
||||
- `session-prefetch-cache` 15s TTL dedup of `syncSession` requests.
|
||||
- `syncSessionInflightByKey` for in-flight dedup.
|
||||
|
||||
## Proposed Architecture
|
||||
|
||||
### Bugfix Layer 0 — Fix rapid project-switch race in sidebar
|
||||
|
||||
**Observed bug:** When connected from desktop to an OpenChamber server, with
|
||||
multiple trees in the sidebar and large sessions (~200k context), rapidly
|
||||
switching between recent sessions in different projects causes the focus to
|
||||
follow the **fetch completion order** instead of the user's final click. The
|
||||
sequence is: user clicks 1, then 2, then 3; UI briefly shows 3; when fetch 1
|
||||
finishes it jumps to 1; when fetch 2 finishes it jumps to 2; when fetch 3
|
||||
finishes it finally lands on 3.
|
||||
|
||||
**Root cause:** `useProjectSessionSelection` (`useProjectSessionSelection.ts`)
|
||||
has auto-selection logic that runs when `activeProjectId` changes. Combined
|
||||
with uncancelled async fetches and cross-project store updates, stale fetch
|
||||
completions update the sidebar session lists, which re-triggers selection
|
||||
fallbacks and overwrites the user's explicit choice.
|
||||
|
||||
**Fix — four parts:**
|
||||
|
||||
1. **Layer 0a — Generation guard in `useEnsureSessionMessages`.**
|
||||
Track a per-hook generation counter. When the effect fires for a new
|
||||
`sessionID`, increment the generation. Ignore the result (do not apply
|
||||
store mutations) if the generation is stale by the time the async fetch
|
||||
completes.
|
||||
|
||||
2. **Layer 0b — Generation guard in `syncSession` / `loadMessages`.**
|
||||
Add a module-level `Map<sessionKey, number>` tracking the latest requested
|
||||
generation per session. Before every store write inside `syncSession` and
|
||||
`loadMessages`, check that the request is still current. This prevents
|
||||
fetches 1 and 2 from mutating state after the user has already selected 3.
|
||||
|
||||
3. **Layer 0c — Debounce `handleSessionSelect`.**
|
||||
In `useSessionActions.ts`, wrap the actual navigation in a short timeout
|
||||
(~80ms). The first click executes immediately; rapid subsequent clicks
|
||||
cancel the pending navigation and reschedule for the latest target. This
|
||||
coalesces 1→2→3 into a single `setCurrentSession(3)` call.
|
||||
|
||||
4. **Layer 0d — Explicit-selection guard in `useProjectSessionSelection`.**
|
||||
Track the timestamp of the last explicit user selection. Suppress the
|
||||
auto-select fallback in the layout effect for ~500ms after an explicit
|
||||
click, so fallback logic never overwrites an active user choice.
|
||||
|
||||
**Effect:** Rapid project switching becomes deterministic: the UI stays on the
|
||||
last clicked session and only loads its content. Stale fetches are silently
|
||||
ignored instead of fighting for focus.
|
||||
|
||||
**Files:** `sync-context.tsx`, `use-sync.ts`, `useSessionActions.ts`,
|
||||
`useProjectSessionSelection.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Layer 1 — Narrow subscriptions in ChatContainer
|
||||
|
||||
**Problem:** `ChatContainer.tsx:436-443` subscribes to the **full directory**
|
||||
`permission` and `question` records. Any permission/question event for **any**
|
||||
session in the directory causes a ChatContainer re-render, even if the event
|
||||
is for an unrelated session. Same for `useSessions(directory)` at line 427
|
||||
— every `session.updated` event re-renders the container.
|
||||
|
||||
There are already session-scoped hooks (`useSessionPermissions(sessionID)`,
|
||||
`useSessionQuestions(sessionID)` at `sync-context.tsx:2151,2165`), but they
|
||||
don't aggregate across descendant subagent sessions — which
|
||||
`collectVisibleSessionIdsForBlockingRequests` (`ChatContainer.tsx:458-464`)
|
||||
handles for the `sessionPermissions` / `sessionQuestions` / `sessionIsWorking`
|
||||
chain.
|
||||
|
||||
**Fix:** Replace the broad directory reads with scoped subtree selectors. The
|
||||
subtree is the current session and all its descendant subagent sessions.
|
||||
|
||||
Add to `sync-context.tsx`:
|
||||
|
||||
- `useScopedSubtreeIds(sessionID: string | null, directory?: string): Set<string>`
|
||||
— returns the set of session IDs in the current subtree (current session +
|
||||
all descendants by `parentID`). Reference-stabilized: a new Set is only
|
||||
returned when the actual membership changes, using the same "compare
|
||||
contents, reuse reference" pattern as `areSessionListsEquivalent`.
|
||||
|
||||
- `useScopedBlockingRequests(sessionID: string | null, directory: string, kind: 'permission' | 'question')`
|
||||
— combines `useScopedSubtreeIds` with the existing `useSessionPermissions` /
|
||||
`useSessionQuestions` patterns to return a flat array of blocking requests
|
||||
scoped to the subtree only.
|
||||
|
||||
- `useParentSession(sessionID: string | null): Session | null` — returns
|
||||
the parent Session via a pre-computed `Map<sessionId, Session>` that only
|
||||
changes when the target session or its parent changes. Replaces the O(N)
|
||||
`.find()` at `ChatContainer.tsx:564`.
|
||||
|
||||
`ChatContainer` then:
|
||||
- reads `useScopedBlockingRequests` instead of the full `allPermissions`/
|
||||
`allQuestions` → `permissionsMap` → `scopedSessionIds` → `sessionPermissions`
|
||||
/ `sessionQuestions` chain.
|
||||
- reads `useParentSession` instead of `sessions.find(...)`.
|
||||
- stops subscribing to `useSessions(directory)` entirely (the subtree selectors
|
||||
provide all needed session data scoped to the active subtree).
|
||||
|
||||
**Effect:** Third-party sessions in the same directory stop re-rendering
|
||||
ChatContainer. Streaming events for unrelated sessions no longer invalidate
|
||||
the chat's blocking-requests chain.
|
||||
|
||||
**Files:** `ChatContainer.tsx`, `sync-context.tsx`.
|
||||
|
||||
---
|
||||
|
||||
### Layer 3 — Module-level projection cache
|
||||
|
||||
**Problem:** `useTurnRecords` keeps `previousProjectionRef` for in-component
|
||||
stabilization, but a session that unmounts and remounts loses that ref. Full
|
||||
re-projection cost is paid every time you return to a session.
|
||||
|
||||
**Fix:** Add a module-level LRU keyed by `sessionKey`. The cache key must
|
||||
capture the exact identity of the message list to avoid stale projections:
|
||||
|
||||
```ts
|
||||
// Includes: sessionKey, message count, last message id,
|
||||
// AND the count of parts on the last message (detects streaming deltas
|
||||
// that add parts to an existing message without changing id or count).
|
||||
type CacheKey = `${sessionKey}|${length}|${lastMessageId ?? ''}|${lastMessagePartCount ?? 0}`
|
||||
const projectionCache = new Map<CacheKey, { projection: TurnProjectionResult }>()
|
||||
```
|
||||
|
||||
Including `lastMessagePartCount` is critical: during streaming, new parts are
|
||||
appended to the same last message — the message ID and total message count
|
||||
stay the same, but the projection must be recomputed.
|
||||
|
||||
Cap at the same limits as `turnModelCache` (30 desktop, 4 vscode/mobile)
|
||||
to bound memory.
|
||||
|
||||
`useTurnRecords` consults the cache before running `projectTurnRecords`; on
|
||||
hit, returns the cached projection and refreshes LRU order; on miss, runs the
|
||||
full projection and writes the result.
|
||||
|
||||
**Effect:** Returning to a session within the LRU window is zero projection
|
||||
work. Combined with `MessageList.timelineCache` (which already caches the
|
||||
virtua snapshot), the first paint after a remount is essentially "rebuild
|
||||
only what's strictly session-specific (resolvers, refs)".
|
||||
|
||||
**Files:** `useTurnRecords.ts` (small extension) + new helper
|
||||
`lib/turns/turnProjectionCache.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Layer 4a — Start message fetch synchronously in setCurrentSession
|
||||
|
||||
**Problem:** `session-ui-store.setCurrentSession` currently only calls
|
||||
`setState` (Zustand). The actual message fetch is started by
|
||||
`ChatContainer.useEffect` at `ChatContainer.tsx:787-792`, which runs on the
|
||||
render AFTER the state update — adding one React commit cycle (~30–80 ms)
|
||||
of latency before the network request starts.
|
||||
|
||||
**Fix:** Start the fetch directly from `setCurrentSession` on the same tick
|
||||
as the state update, using the existing imperative access layer.
|
||||
|
||||
The approach uses `session-actions.ts`, which already has module-level refs
|
||||
(`_sdk`, `_childStores`, `_getDirectory` — `session-actions.ts:33-35`) set by
|
||||
SyncProvider at mount time. Add a `fetchMessagesForSession(sessionID: string)`
|
||||
function there that duplicates the core path from `useSync().syncSession`:
|
||||
checks the child store for existing messages, fetches from SDK if needed,
|
||||
calls `materializeSessionSnapshots`, and writes to the directory store.
|
||||
|
||||
`setCurrentSession` then calls `fetchMessagesForSession(targetId)` immediately
|
||||
after `set({ currentSessionId: id, ... })`. The `syncSessionInflightByKey`
|
||||
in `use-sync.ts` doesn't cover this new path, so the function also uses the
|
||||
existing `_ensureMessagesLoading` Set in `sync-context.tsx` (line 2668) for
|
||||
dedup.
|
||||
|
||||
The existing `ChatContainer.useEffect` at line 787 stays as a safety net for
|
||||
sessions restored from URL or other non-sidebar entry points.
|
||||
|
||||
**Effect:** Fetch starts on the same tick as the state update, ~30–80 ms
|
||||
earlier than the current "wait for React commit → useEffect → fetch" path.
|
||||
|
||||
**Files:** `session-ui-store.ts` (one new call in `setCurrentSession`),
|
||||
`session-actions.ts` (new `fetchMessagesForSession` function).
|
||||
|
||||
---
|
||||
|
||||
### Layer 5 — Small wins
|
||||
|
||||
- **`reviewTransferDirection` pre-computed map.** In
|
||||
`MessageList.tsx:1151-1164`, replace
|
||||
`useGlobalSessionsStore((state) => state.activeSessions.find(...))` with
|
||||
a selector that reads a pre-computed `Map<sessionId, ReviewTransferDirection>`
|
||||
maintained by `useGlobalSessionsStore` (built once per active-sessions
|
||||
change, in the same reducer that updates `activeSessions`). Same selector
|
||||
shape from the component's perspective, but no `.find()` + `.some()` on
|
||||
every global store change.
|
||||
|
||||
- **`parentSession` lookup.** Part of Layer 1 (see `useParentSession` hook
|
||||
above). The pre-computed `Map<sessionId, Session>` is maintained in
|
||||
`sync-context.tsx` and read via the hook, eliminating the O(N) `.find()`
|
||||
in `ChatContainer`.
|
||||
|
||||
**Files:** `MessageList.tsx`, `useGlobalSessionsStore.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Layer 6a — Reduce initial payload
|
||||
|
||||
Lower the page sizes to match typical user reading patterns:
|
||||
|
||||
- `INITIAL_MESSAGE_PAGE_SIZE`: 150 → 50 (desktop), 30 stays (vscode/mobile).
|
||||
- `HISTORY_MESSAGE_PAGE_SIZE`: 200 → 100.
|
||||
- `MESSAGE_REFETCH_LIMIT`: 200 → 100.
|
||||
- `DEFAULT_MESSAGE_LIMIT` in `sessionTypes.ts`: stays 200 (it's a ceiling,
|
||||
not a fetch target).
|
||||
|
||||
**How the "load more" indicator still works with a smaller initial page:**
|
||||
`historyMeta` in ChatContainer is built from `sync.hasMore(currentSessionId)`,
|
||||
which reads `meta.current` (set immediately after the first `loadMessages`
|
||||
call, before the component renders). If the server returns a cursor,
|
||||
`sync.hasMore()` returns `true` → `historyMeta.complete = false` →
|
||||
`hasMoreAboveTurns = true` → scroll-up indicator appears correctly.
|
||||
The `messages.length >= defaultLimit` fallback is never reached in practice
|
||||
because `historyMeta` is populated before the first render of the chat.
|
||||
|
||||
Effect on the 1.5 MB problem:
|
||||
|
||||
- Initial fetch payload: 1.5 MB → ~500 KB parsed (50 messages).
|
||||
- JSON.parse cost: 100–200 ms → 30–70 ms.
|
||||
- projection cost: 30–100 ms → 10–30 ms.
|
||||
- Combined main-thread block: 200–500 ms → 50–150 ms.
|
||||
|
||||
User cost: one extra round trip when scrolling past the 50th message. The
|
||||
scroll-triggered `loadMore` is already in place and is the right UX for "I
|
||||
want to see older messages" — the previous behavior of "fetch 150 and ignore
|
||||
100" was wasted work for the common case.
|
||||
|
||||
**Files:** `use-sync.ts`, `session-actions.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Layer 6b — Progressive mount
|
||||
|
||||
`use-sync.syncSession` starts the initial 50-message page synchronously.
|
||||
After the first page resolves, if the cursor indicates more messages, a
|
||||
second fetch of `HISTORY_MESSAGE_PAGE_SIZE` (100 messages) is dispatched
|
||||
via `loadMessages(sessionID, { before: cursor, mode: "prepend" })` — the
|
||||
prepend mode already exists in `loadMessages` (`use-sync.ts:373`). This
|
||||
second page is non-blocking; the user sees the first 50 messages immediately.
|
||||
|
||||
On the client side, `useTurnRecords` and `useChatTimelineController` must
|
||||
re-run projection when the prepended messages arrive. Two options:
|
||||
|
||||
- **Simple:** Let the full re-projection run on the second page arrival
|
||||
(50→150 messages). With Layer 6a, projection of 150 messages is ~10–30 ms —
|
||||
fast enough that a one-time re-project on the second page is acceptable.
|
||||
|
||||
- **Optimal:** Extend `updateTurnWindowModelIncremental` (currently handles
|
||||
only +1 message appends — `windowTurns.ts:76-84` checks
|
||||
`nextMessages.length !== previousMessages.length + 1`) to handle batch
|
||||
prepends. This requires:
|
||||
1. A new `updateTurnWindowModelBatchPrepend` that verifies the new messages
|
||||
are all prepended (all existing message references match at the tail)
|
||||
and projects only the new messages into turn windows.
|
||||
2. A corresponding `updateTurnProjectionIncremental` in `projectTurnRecords.ts`
|
||||
that merges the new turns into the existing projection.
|
||||
|
||||
Because `updateTurnWindowModelIncremental` is designed for single-message
|
||||
streaming deltas (not batch prepends), this is non-trivial additional work.
|
||||
Given Layer 6a alone reduces the main-thread block to ~50–150 ms, start
|
||||
with the simple approach and measure before committing to the incremental
|
||||
path.
|
||||
|
||||
**Net effect:** The first 50 messages mount within the same time as today
|
||||
(possibly faster, because parsing 500 KB < parsing 1.5 MB). The next 100
|
||||
arrive ~200–400 ms later. From the user's perspective, the chat becomes
|
||||
interactive immediately.
|
||||
|
||||
**Files:** `use-sync.ts`, `ChatContainer.tsx` (small — no change to `loadMore` UX).
|
||||
|
||||
---
|
||||
|
||||
## What I do not propose to touch (and why)
|
||||
|
||||
- `useChatAutoFollow` and the scroll-restoration logic in
|
||||
`useChatTimelineController` (the `prePrependScrollRef` / height-delta
|
||||
compensation). PR #1282 closed because of scroll-restore regressions;
|
||||
these are working and tested.
|
||||
- The event-pipeline coalescing and `message.part.delta` reducer path.
|
||||
Already correct (sync/DOCUMENTATION.md).
|
||||
- The Markdown renderer and Prism highlight deferral. PR #1651 already took
|
||||
the low-hanging fruit.
|
||||
- The `key={currentSessionId}` on `<ChatViewport>`. Forces a full remount,
|
||||
which is expensive, but a previous PR attempt (Layer 2 in the sidebar
|
||||
plan) regressed scroll/follow. Keeping the remount for now.
|
||||
- The OpenCode server response shape. It's in `anomalyco/opencode`, which
|
||||
AGENTS.md forbids us from touching.
|
||||
- The SDK's internal `JSON.parse` step. Replacing it with a streaming
|
||||
parser requires owning the fetch call (the SDK doesn't expose the
|
||||
`ReadableStream`), and the response is a single JSON array, not NDJSON —
|
||||
so streaming buys us the same "first N visible, rest in background"
|
||||
pattern that Layer 6b already implements.
|
||||
- Tool output lazy-load. Would need a server-side endpoint like
|
||||
`GET /api/session/{sid}/part/{partID}` that the OpenCode server does
|
||||
not currently provide.
|
||||
|
||||
## Expected effect
|
||||
|
||||
- **Initial session switch on a 150-message tool-heavy session:**
|
||||
main-thread block 200–500 ms → 50–150 ms. ~3x faster.
|
||||
- **Wire payload on the same session:** 1.5 MB parsed → 500 KB parsed.
|
||||
After gzip, ~300 KB → ~100 KB.
|
||||
- **Returning to a session within the LRU window:** zero projection work
|
||||
(Layer 3), zero scroll-cache rebuild (existing `timelineCache`).
|
||||
- **Returning to a session outside the LRU window:** same as initial, but
|
||||
the 15s `session-prefetch-cache` TTL catches sessions visited twice in
|
||||
rapid succession.
|
||||
- **Server load:** the OpenCode server processes 1/3 the volume per
|
||||
session-switch, and the warm second page in Layer 6b is deduped
|
||||
against the inflight request. Strictly less load than today.
|
||||
|
||||
## Files Touched by the Plan
|
||||
|
||||
- `CHAT_PERF_PLAN.md` (new, this file)
|
||||
- `packages/ui/src/sync/sync-context.tsx` (Bugfix Layer 0a — `useEnsureSessionMessages` guard)
|
||||
- `packages/ui/src/sync/use-sync.ts` (Bugfix Layer 0b — `syncSession`/`loadMessages` guard; Layer 6a — constants)
|
||||
- `packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts` (Bugfix Layer 0c — debounce)
|
||||
- `packages/ui/src/components/session/sidebar/hooks/useProjectSessionSelection.ts` (Bugfix Layer 0d — explicit-selection guard)
|
||||
- `packages/ui/src/components/chat/ChatContainer.tsx` (Layers 1, 6b)
|
||||
- `packages/ui/src/components/chat/MessageList.tsx` (Layer 5a)
|
||||
- `packages/ui/src/stores/useGlobalSessionsStore.ts` (Layer 5a — pre-computed map)
|
||||
- `packages/ui/src/sync/sync-context.tsx` (Layer 1 — new hooks: `useScopedSubtreeIds`, `useScopedBlockingRequests`, `useParentSession`)
|
||||
- `packages/ui/src/sync/session-ui-store.ts` (Layer 4a — call fetch from `setCurrentSession`)
|
||||
- `packages/ui/src/sync/session-actions.ts` (Layer 4a — new `fetchMessagesForSession`, Layer 6a — constants)
|
||||
- `packages/ui/src/sync/use-sync.ts` (Layer 6a — constants, Layer 6b — progressive fetch)
|
||||
- `packages/ui/src/stores/types/sessionTypes.ts` (Layer 6a — sync `DEFAULT_MESSAGE_LIMIT` with new constants)
|
||||
- `packages/ui/src/components/chat/hooks/useTurnRecords.ts` (Layer 3 — projection cache)
|
||||
- `packages/ui/src/components/chat/lib/turns/turnProjectionCache.ts` (new, Layer 3)
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **Bugfix Layer 0** (race condition fixes) — highest priority, fixes the
|
||||
observed sidebar switching bug before any optimization work.
|
||||
2. **Layer 6a** (reduce constants) — simplest performance win, also reduces
|
||||
the race window.
|
||||
3. **Layer 3** (projection cache) — isolated change, immediate return-visit win.
|
||||
4. **Layer 5a** (reviewTransfer map) — isolated, low risk.
|
||||
5. **Layer 1** (narrow subscriptions) — more complex but well-bounded.
|
||||
6. **Layer 4a** (early fetch) — depends on Layer 1 for dedup safety.
|
||||
7. **Layer 6b** (progressive mount) — highest complexity, implement after
|
||||
measuring real-world gains from 6a.
|
||||
|
||||
## Conflict risks
|
||||
|
||||
- None of the open PRs touch the files above in a conflicting way. PR #1584
|
||||
(draft, long user message collapse) modifies `UserTextPart.tsx`, not the
|
||||
files we touch. PR #1621 (draft, comprehensive scrolling) modifies scroll
|
||||
and navigation components, not the chat data flow. PR #1448 (draft,
|
||||
unified multi-server sidebar) is for `SessionSidebar.tsx` and family, not
|
||||
the chat panel.
|
||||
- Existing internal benchmarks for `event-pipeline` are unaffected
|
||||
(Layers 1, 3, 4a, 5, 6 do not touch event coalescing or ordering).
|
||||
- `bun run type-check` and `bun run lint` must remain green.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Layer 2 (drop `key={currentSessionId}` from `ChatViewport`):** explicitly
|
||||
out of scope per user. Revisit after the lower-risk layers ship.
|
||||
2. **Layer 7 (Web Worker for `JSON.parse` + materialize):** out of scope.
|
||||
The Layer 6a/6b combo should bring main-thread block to ~50–150 ms,
|
||||
which is acceptable. Revisit if real-user feedback still shows jank.
|
||||
3. **Layer 6b incremental projection for batch prepends:** start with the
|
||||
simple full-reproject-on-second-page approach (50→150 projection is
|
||||
~10–30 ms post-Layer-6a). Only build the incremental path if benchmarks
|
||||
show the full reproject is still noticeable.
|
||||
4. **Layer 3 cache invalidation during streaming:** the `lastMessagePartCount`
|
||||
in the cache key catches part deltas on the last message. If the streaming
|
||||
model appends parts to a message that is NOT the last (e.g., tool output
|
||||
on a completed turn while a new turn is streaming), the cache would miss —
|
||||
which is correct (projection must re-run). Confirm this edge case during
|
||||
smoke testing.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Right Sidebar Performance Plan
|
||||
|
||||
## Status — DONE (PR #1674)
|
||||
|
||||
### P0 — Correctness / leak fixes
|
||||
|
||||
- [x] **RightSidebar**: drop dead `useEffect` that re-nulled refs the resize handler already nulled; collapse redundant `width`/`minWidth`/`maxWidth` triple into `width` + the existing `--oc-right-sidebar-width` variable.
|
||||
- [x] **useUIStore**: clamp `setRightSidebarWidth` to `[MIN, MAX]`; simplify `setRightSidebarOpen` (22 lines → 12).
|
||||
- [x] **RightSidebarTabs / `useRightSidebarGitSync`**: takes right tab + main tab; only polls when the right git tab is the visible consumer AND the browser is online + visible. Replaces a poll that ran for the lifetime of any open sidebar.
|
||||
- [x] **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.
|
||||
- [x] **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.
|
||||
- [x] **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.
|
||||
|
||||
### P1 — Render fanout
|
||||
|
||||
- [x] **RightSidebarTabs**: all three tab content components 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`.
|
||||
- [x] **GitView**: 13 separate `useGitStore` action selectors collapsed into one `useShallow` block (one re-evaluation per store change instead of 13).
|
||||
- [x] **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.
|
||||
- [x] **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.
|
||||
- [x] **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. `badgeByDir` walks each file's path segments and increments counters for every ancestor dir.
|
||||
- [x] **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.
|
||||
- [x] **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.
|
||||
- [x] **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.
|
||||
|
||||
### Review fixes (post-review)
|
||||
|
||||
- [x] **Blocker**: `inFlightDirsRef` leak — removed `isCancelled` guard from `finally` so inflight flag always cleans up. Re-expanding a cancelled directory correctly retries.
|
||||
- [x] **Non-blocker**: duplicate `RIGHT_SIDEBAR_MAX_WIDTH` — exported from `useUIStore`, imported in `RightSidebar`. Single source of truth.
|
||||
- [x] **Non-blocker**: `useRightSidebarGitSync` poll gating — verified `shouldPoll` already includes `rightTab === 'git'`. No code change needed.
|
||||
|
||||
## Files modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `packages/ui/src/components/layout/RightSidebar.tsx` | Drop dead useEffect, collapse width props, import constants from store |
|
||||
| `packages/ui/src/components/layout/RightSidebarTabs.tsx` | Always-mount tabs, gated poll, redirect effect, type-guarded onSelect |
|
||||
| `packages/ui/src/components/layout/SidebarFilesTree.tsx` | Precomputed maps, React.memo(FileRow), isCancelled predicate, LRU cache |
|
||||
| `packages/ui/src/components/git/GitView.tsx` | Cancelled + Promise.all, isGitViewActive, useShallow, LRU snapshots |
|
||||
| `packages/ui/src/components/project/ProjectNotesTodoPanel.tsx` | Debounce cancel on blur, chained persistProjectData |
|
||||
| `packages/ui/src/stores/useUIStore.ts` | Clamp setRightSidebarWidth, simplify setRightSidebarOpen, export constants |
|
||||
| `packages/ui/src/hooks/useGitmojiList.ts` | **New** — module-level inflight + subscribers, localStorage cache |
|
||||
|
||||
## Architecture notes
|
||||
|
||||
- The 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 `useRightSidebarGitSync` 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 `loadDirectory` cancellation predicate prevents stale state writes in `try`/`catch`; the `finally` block always cleans up `inFlightDirsRef`.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
- Virtualization of `SidebarFilesTree` (large refactor; current precomputed maps already address the main bottlenecks).
|
||||
- Lazy plan titles (requires `openchamberConfig` schema changes).
|
||||
- Extracting remaining sub-views from `GitView` (large refactor, not perf-critical).
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user