chore(app): solidjs refactoring (#13399)
parent
0a3a3216db
commit
8176bafc55
|
|
@ -0,0 +1,515 @@
|
||||||
|
# CreateEffect Simplification Implementation Spec
|
||||||
|
|
||||||
|
Reduce reactive misuse across `packages/app`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
|
||||||
|
|
||||||
|
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
|
||||||
|
|
||||||
|
Key issues from the audit:
|
||||||
|
|
||||||
|
- Derived state is being written through effects instead of computed directly
|
||||||
|
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
|
||||||
|
- User-driven actions are hidden inside reactive effects
|
||||||
|
- Context layers mirror and hydrate child stores with multiple sync effects
|
||||||
|
- Several areas repeat the same imperative trigger pattern in multiple effects
|
||||||
|
|
||||||
|
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Cut high-churn `createEffect` usage in the hottest files first
|
||||||
|
- Replace effect-driven derived state with reactive derivation
|
||||||
|
- Replace reset-on-key effects with keyed ownership boundaries
|
||||||
|
- Move event-driven work to direct actions and write paths
|
||||||
|
- Remove mirrored store hydration where a single source of truth can exist
|
||||||
|
- Leave necessary external sync effects in place, but make them narrower and clearer
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not rewrite unrelated component structure just to reduce the count
|
||||||
|
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
|
||||||
|
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
|
||||||
|
- Do not attempt a repo-wide cleanup outside `packages/app`
|
||||||
|
|
||||||
|
## Effect Taxonomy And Replacement Rules
|
||||||
|
|
||||||
|
Use these rules during implementation.
|
||||||
|
|
||||||
|
### Prefer `createMemo`
|
||||||
|
|
||||||
|
Use `createMemo` when the target value is pure derived state from other signals or stores.
|
||||||
|
|
||||||
|
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
|
||||||
|
|
||||||
|
Apply this to:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:141`
|
||||||
|
- `packages/app/src/pages/layout.tsx:557`
|
||||||
|
- `packages/app/src/components/terminal.tsx:261`
|
||||||
|
- `packages/app/src/components/session/session-header.tsx:309`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- If no external system is touched, do not use `createEffect`
|
||||||
|
- Derive once, then read the memo where needed
|
||||||
|
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
|
||||||
|
|
||||||
|
### Prefer Keyed Remounts
|
||||||
|
|
||||||
|
Use keyed remounts when local UI state should reset because an identity changed.
|
||||||
|
|
||||||
|
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
|
||||||
|
|
||||||
|
Apply this to:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:325`
|
||||||
|
- `packages/app/src/pages/session.tsx:336`
|
||||||
|
- `packages/app/src/pages/session.tsx:477`
|
||||||
|
- `packages/app/src/pages/session.tsx:869`
|
||||||
|
- `packages/app/src/pages/session.tsx:963`
|
||||||
|
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||||
|
- `packages/app/src/context/file.tsx:100`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- If the desired behavior is "new identity, fresh local state," key the owner subtree
|
||||||
|
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
|
||||||
|
|
||||||
|
### Prefer Event Handlers And Actions
|
||||||
|
|
||||||
|
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
|
||||||
|
|
||||||
|
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
|
||||||
|
|
||||||
|
Apply this to:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/layout.tsx:484`
|
||||||
|
- `packages/app/src/pages/layout.tsx:652`
|
||||||
|
- `packages/app/src/pages/layout.tsx:776`
|
||||||
|
- `packages/app/src/pages/layout.tsx:1489`
|
||||||
|
- `packages/app/src/pages/layout.tsx:1519`
|
||||||
|
- `packages/app/src/components/file-tree.tsx:328`
|
||||||
|
- `packages/app/src/pages/session/terminal-panel.tsx:55`
|
||||||
|
- `packages/app/src/context/global-sync.tsx:148`
|
||||||
|
- Duplicated trigger sets in:
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- If the trigger is user intent, call the action at the source of that intent
|
||||||
|
- If the same imperative work is triggered from multiple places, extract one function and call it directly
|
||||||
|
|
||||||
|
### Prefer `onMount` And `onCleanup`
|
||||||
|
|
||||||
|
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
|
||||||
|
|
||||||
|
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
|
||||||
|
|
||||||
|
Use this when:
|
||||||
|
|
||||||
|
- Setup should happen once per owner lifecycle
|
||||||
|
- Cleanup should always pair with teardown
|
||||||
|
- The work is not conceptually derived state
|
||||||
|
|
||||||
|
### Keep `createEffect` When It Is A Real Bridge
|
||||||
|
|
||||||
|
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
|
||||||
|
|
||||||
|
Examples that should remain, though they may be narrowed or split:
|
||||||
|
|
||||||
|
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
|
||||||
|
- Scroll sync in `packages/app/src/pages/session.tsx:685`
|
||||||
|
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||||
|
- External sync in:
|
||||||
|
- `packages/app/src/context/language.tsx:207`
|
||||||
|
- `packages/app/src/context/settings.tsx:110`
|
||||||
|
- `packages/app/src/context/sdk.tsx:26`
|
||||||
|
- Polling in:
|
||||||
|
- `packages/app/src/components/status-popover.tsx:59`
|
||||||
|
- `packages/app/src/components/dialog-select-server.tsx:273`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Keep the effect single-purpose
|
||||||
|
- Make dependencies explicit and narrow
|
||||||
|
- Avoid writing back into the same reactive graph unless absolutely required
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 0: Classification Pass
|
||||||
|
|
||||||
|
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
|
||||||
|
- Shared helpers to be introduced are identified up front to avoid repeating patterns
|
||||||
|
|
||||||
|
### Phase 1: Derived-State Cleanup
|
||||||
|
|
||||||
|
Tackle highest-value, lowest-risk derived-state cleanup first.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
|
||||||
|
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
|
||||||
|
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
|
||||||
|
- Replace other obvious derived-state effects in terminal and session header
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
|
||||||
|
- Targeted derived-state effects are deleted, not just moved
|
||||||
|
|
||||||
|
### Phase 2: Keyed Reset Cleanup
|
||||||
|
|
||||||
|
Replace reset-on-key effects with keyed ownership boundaries.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Key session-scoped UI and state by `sessionKey`
|
||||||
|
- Key file-scoped state by `scope()`
|
||||||
|
- Remove manual clear-and-reseed effects in session and file context
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Switching session or file scope recreates the intended local state cleanly
|
||||||
|
- No stale state leaks across session or scope changes
|
||||||
|
- Target reset effects are deleted
|
||||||
|
|
||||||
|
### Phase 3: Event-Driven Work Extraction
|
||||||
|
|
||||||
|
Move event-driven work out of reactive effects.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Replace `globalStore.reload` effect dispatching with direct calls
|
||||||
|
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
|
||||||
|
- Collapse duplicated imperative trigger triplets into single functions
|
||||||
|
- Move file-tree and terminal-panel imperative work to explicit handlers
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- User-triggered behavior still fires exactly once per intended action
|
||||||
|
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
|
||||||
|
|
||||||
|
### Phase 4: Context Ownership Cleanup
|
||||||
|
|
||||||
|
Remove mirrored child-store hydration patterns.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
|
||||||
|
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
|
||||||
|
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- There is one clear source of truth for each synced value
|
||||||
|
- Child stores no longer need effect-based hydration to stay consistent
|
||||||
|
- Initialization and updates both work without manual mirror effects
|
||||||
|
|
||||||
|
### Phase 5: Cleanup And Keeper Review
|
||||||
|
|
||||||
|
Clean up remaining targeted hotspots and narrow the effects that should stay.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
|
||||||
|
- Mixed-responsibility effects are split into smaller units where still needed
|
||||||
|
|
||||||
|
## Detailed Work Items By Area
|
||||||
|
|
||||||
|
### 1. Normalize Tab State
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:141`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Move tab normalization into the functions that create, load, or update tab state
|
||||||
|
- Make readers consume already-normalized tab data
|
||||||
|
- Remove the effect that rewrites derived tab state after the fact
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Tabs should become valid when written, not be repaired later
|
||||||
|
- This removes a feedback loop and makes state easier to trust
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/pages/session.tsx:141` is removed
|
||||||
|
- Newly created and restored tabs are normalized before they enter local state
|
||||||
|
- Tab rendering still matches current behavior for valid and edge-case inputs
|
||||||
|
|
||||||
|
### 2. Key Session-Owned State
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:325`
|
||||||
|
- `packages/app/src/pages/session.tsx:336`
|
||||||
|
- `packages/app/src/pages/session.tsx:477`
|
||||||
|
- `packages/app/src/pages/session.tsx:869`
|
||||||
|
- `packages/app/src/pages/session.tsx:963`
|
||||||
|
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Identify state that should reset when `sessionKey` changes
|
||||||
|
- Move that state under a keyed subtree or keyed owner boundary
|
||||||
|
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Session identity already defines the lifetime of this UI state
|
||||||
|
- Keyed ownership makes reset behavior automatic and easier to reason about
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The targeted reset effects are removed
|
||||||
|
- Changing sessions resets only the intended session-local state
|
||||||
|
- Scroll and editor state that should persist are not accidentally reset
|
||||||
|
|
||||||
|
### 3. Derive Workspace Order
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/layout.tsx:557`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Stop writing `workspaceOrder` from live workspace data in an effect
|
||||||
|
- Represent user overrides separately from live workspace data
|
||||||
|
- Compute effective order from current data plus overrides with a memo or pure helper
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Persisted user intent and live source data should not mirror each other through an effect
|
||||||
|
- A computed effective order avoids drift and racey resync behavior
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
|
||||||
|
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
|
||||||
|
- User overrides persist without requiring a sync-back effect
|
||||||
|
|
||||||
|
### 4. Remove Child-Store Mirrors
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/context/global-sync.tsx:130`
|
||||||
|
- `packages/app/src/context/global-sync.tsx:138`
|
||||||
|
- `packages/app/src/context/global-sync.tsx:148`
|
||||||
|
- `packages/app/src/context/global-sync/child-store.ts:184`
|
||||||
|
- `packages/app/src/context/global-sync/child-store.ts:190`
|
||||||
|
- `packages/app/src/context/global-sync/child-store.ts:193`
|
||||||
|
- `packages/app/src/context/layout.tsx:424`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Trace the actual ownership of global and child store values
|
||||||
|
- Replace hydration and mirror effects with explicit initialization and direct updates
|
||||||
|
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Mirrors make it hard to tell which state is authoritative
|
||||||
|
- Event-bus style state toggles hide control flow and create accidental reruns
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Child store hydration no longer depends on effect-based copying
|
||||||
|
- Reload work can be followed from the event source to the handler without a reactive relay
|
||||||
|
- State remains correct on first load, child creation, and subsequent updates
|
||||||
|
|
||||||
|
### 5. Key File-Scoped State
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/context/file.tsx:100`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Move file-scoped local state under a boundary keyed by `scope()`
|
||||||
|
- Remove any effect that watches `scope()` only to reset file-local state
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- File scope changes are identity changes
|
||||||
|
- Keyed ownership gives a cleaner reset than manual clear logic
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/context/file.tsx:100` is removed
|
||||||
|
- Switching scopes resets only scope-local state
|
||||||
|
- No previous-scope data appears after a scope change
|
||||||
|
|
||||||
|
### 6. Split Layout Side Effects
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/layout.tsx:1489`
|
||||||
|
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
|
||||||
|
- Move user-triggered branches into the actual command or handler that causes them
|
||||||
|
- Remove any branch that only exists because one effect is handling unrelated concerns
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Mixed effects hide cause and make reruns hard to predict
|
||||||
|
- Smaller units reduce accidental coupling and make future cleanup safer
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
|
||||||
|
- Event-driven branches execute from direct handlers
|
||||||
|
- Remaining effects in this area each have one clear external sync purpose
|
||||||
|
|
||||||
|
### 7. Remove Duplicate Triggers
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Extract one explicit imperative function per behavior
|
||||||
|
- Call that function from each source event instead of replicating the same effect pattern multiple times
|
||||||
|
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Duplicate triggers make it easy to miss a case or fire twice
|
||||||
|
- One named action is easier to test and reason about
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Repeated imperative effect triplets are collapsed into shared functions
|
||||||
|
- Scroll behavior still works, including hash-based navigation
|
||||||
|
- No duplicate firing is introduced
|
||||||
|
|
||||||
|
### 8. Make Prompt Filtering Reactive
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/components/prompt-input.tsx:652`
|
||||||
|
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
|
||||||
|
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Filtering is classic derived state
|
||||||
|
- It should not need an effect if it can be computed from current inputs
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
|
||||||
|
- Filtered slash-command results update correctly as the input changes
|
||||||
|
- The editor sync effect at `:690` still behaves correctly
|
||||||
|
|
||||||
|
### 9. Clean Up Smaller Derived-State Cases
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/components/terminal.tsx:261`
|
||||||
|
- `packages/app/src/components/session/session-header.tsx:309`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Replace effect-written local state with memos or inline derivation
|
||||||
|
- Remove intermediate setters when the value can be computed directly
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- These are low-risk wins that reinforce the same pattern
|
||||||
|
- They also help keep follow-up cleanup consistent
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Targeted effects are removed
|
||||||
|
- UI output remains unchanged under the same inputs
|
||||||
|
|
||||||
|
## Verification And Regression Checks
|
||||||
|
|
||||||
|
Run focused checks after each phase, not only at the end.
|
||||||
|
|
||||||
|
### Suggested Verification
|
||||||
|
|
||||||
|
- Switch between sessions rapidly and confirm local session UI resets only where intended
|
||||||
|
- Open, close, and reorder tabs and confirm order and normalization remain stable
|
||||||
|
- Change workspaces, reload workspace data, and verify effective ordering is correct
|
||||||
|
- Change file scope and confirm stale file state does not bleed across scopes
|
||||||
|
- Trigger layout actions that previously depended on effects and confirm they still fire once
|
||||||
|
- Use slash commands in the prompt and verify filtering updates as you type
|
||||||
|
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
|
||||||
|
- Verify global sync initialization, reload, and child-store creation paths
|
||||||
|
|
||||||
|
### Regression Checks
|
||||||
|
|
||||||
|
- No accidental infinite reruns
|
||||||
|
- No double-firing network or command actions
|
||||||
|
- No lost cleanup for listeners, timers, or scroll handlers
|
||||||
|
- No preserved stale state after identity changes
|
||||||
|
- No removed effect that was actually bridging to DOM or an external API
|
||||||
|
|
||||||
|
If available, add or update tests around pure helpers introduced during this cleanup.
|
||||||
|
|
||||||
|
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
|
||||||
|
|
||||||
|
## Definition Of Done
|
||||||
|
|
||||||
|
This work is done when all of the following are true:
|
||||||
|
|
||||||
|
- The highest-leverage targets in this spec are implemented
|
||||||
|
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
|
||||||
|
- The "should remain" effects still exist only where they serve a real external sync purpose
|
||||||
|
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
|
||||||
|
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
|
||||||
|
- No behavior regressions are found in the targeted areas
|
||||||
|
|
||||||
|
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
|
||||||
|
|
||||||
|
The main success metric is clearer ownership and fewer effect-driven state repairs.
|
||||||
|
|
||||||
|
## Risks And Rollout Notes
|
||||||
|
|
||||||
|
Main risks:
|
||||||
|
|
||||||
|
- Keyed remounts can reset too much if state boundaries are drawn too high
|
||||||
|
- Store mirror removal can break initialization order if ownership is not mapped first
|
||||||
|
- Moving event work out of effects can accidentally skip triggers that were previously implicit
|
||||||
|
|
||||||
|
Rollout notes:
|
||||||
|
|
||||||
|
- Land in small phases, with each phase keeping the app behaviorally stable
|
||||||
|
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
|
||||||
|
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
|
||||||
|
|
@ -325,12 +325,6 @@ export default function FileTree(props: {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const dir = file.tree.state(props.path)
|
|
||||||
if (!shouldListExpanded({ level, dir })) return
|
|
||||||
void file.tree.list(props.path)
|
|
||||||
})
|
|
||||||
|
|
||||||
const nodes = createMemo(() => {
|
const nodes = createMemo(() => {
|
||||||
const nodes = file.tree.children(props.path)
|
const nodes = file.tree.children(props.path)
|
||||||
const current = filter()
|
const current = filter()
|
||||||
|
|
|
||||||
|
|
@ -591,7 +591,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
setActive: setSlashActive,
|
setActive: setSlashActive,
|
||||||
onInput: slashOnInput,
|
onInput: slashOnInput,
|
||||||
onKeyDown: slashOnKeyDown,
|
onKeyDown: slashOnKeyDown,
|
||||||
refetch: slashRefetch,
|
|
||||||
} = useFilteredList<SlashCommand>({
|
} = useFilteredList<SlashCommand>({
|
||||||
items: slashCommands,
|
items: slashCommands,
|
||||||
key: (x) => x?.id,
|
key: (x) => x?.id,
|
||||||
|
|
@ -648,14 +647,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => sync.data.command,
|
|
||||||
() => slashRefetch(),
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auto-scroll active command into view when navigating with keyboard
|
// Auto-scroll active command into view when navigating with keyboard
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const activeId = slashActive()
|
const activeId = slashActive()
|
||||||
|
|
|
||||||
|
|
@ -306,11 +306,10 @@ export function SessionHeader() {
|
||||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
|
|
||||||
createEffect(() => {
|
const selectApp = (app: OpenApp) => {
|
||||||
const value = prefs.app
|
if (!options().some((item) => item.id === app)) return
|
||||||
if (options().some((o) => o.id === value)) return
|
setPrefs("app", app)
|
||||||
setPrefs("app", options()[0]?.id ?? "finder")
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const openDir = (app: OpenApp) => {
|
const openDir = (app: OpenApp) => {
|
||||||
if (opening() || !canOpen() || !platform.openPath) return
|
if (opening() || !canOpen() || !platform.openPath) return
|
||||||
|
|
@ -458,7 +457,7 @@ export function SessionHeader() {
|
||||||
value={current().id}
|
value={current().id}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||||
setPrefs("app", value as OpenApp)
|
selectApp(value as OpenApp)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<For each={options()}>
|
<For each={options()}>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||||
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
import { SerializeAddon } from "@/addons/serialize"
|
import { SerializeAddon } from "@/addons/serialize"
|
||||||
import { matchKeybind, parseKeybind } from "@/context/command"
|
import { matchKeybind, parseKeybind } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
@ -219,7 +219,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
|
const terminalColors = createMemo(getTerminalColors)
|
||||||
|
|
||||||
const scheduleFit = () => {
|
const scheduleFit = () => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
|
|
@ -259,8 +259,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const colors = getTerminalColors()
|
const colors = terminalColors()
|
||||||
setTerminalColors(colors)
|
|
||||||
if (!term) return
|
if (!term) return
|
||||||
setOptionIfSupported(term, "theme", colors)
|
setOptionIfSupported(term, "theme", colors)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
createEffect,
|
|
||||||
getOwner,
|
getOwner,
|
||||||
Match,
|
Match,
|
||||||
onCleanup,
|
onCleanup,
|
||||||
|
|
@ -35,7 +34,6 @@ import { trimSessions } from "./global-sync/session-trim"
|
||||||
import type { ProjectMeta } from "./global-sync/types"
|
import type { ProjectMeta } from "./global-sync/types"
|
||||||
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
||||||
import { sanitizeProject } from "./global-sync/utils"
|
import { sanitizeProject } from "./global-sync/utils"
|
||||||
import { usePlatform } from "./platform"
|
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
|
|
@ -54,7 +52,6 @@ type GlobalStore = {
|
||||||
|
|
||||||
function createGlobalSync() {
|
function createGlobalSync() {
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
if (!owner) throw new Error("GlobalSync must be created within owner")
|
if (!owner) throw new Error("GlobalSync must be created within owner")
|
||||||
|
|
@ -64,7 +61,7 @@ function createGlobalSync() {
|
||||||
const sessionLoads = new Map<string, Promise<void>>()
|
const sessionLoads = new Map<string, Promise<void>>()
|
||||||
const sessionMeta = new Map<string, { limit: number }>()
|
const sessionMeta = new Map<string, { limit: number }>()
|
||||||
|
|
||||||
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
|
const [projectCache, setProjectCache, projectInit] = persisted(
|
||||||
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
Persist.global("globalSync.project", ["globalSync.project.v1"]),
|
||||||
createStore({ value: [] as Project[] }),
|
createStore({ value: [] as Project[] }),
|
||||||
)
|
)
|
||||||
|
|
@ -80,6 +77,57 @@ function createGlobalSync() {
|
||||||
reload: undefined,
|
reload: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let active = true
|
||||||
|
let projectWritten = false
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
active = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const cacheProjects = () => {
|
||||||
|
setProjectCache(
|
||||||
|
"value",
|
||||||
|
untrack(() => globalStore.project.map(sanitizeProject)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setProjects = (next: Project[] | ((draft: Project[]) => void)) => {
|
||||||
|
projectWritten = true
|
||||||
|
if (typeof next === "function") {
|
||||||
|
setGlobalStore("project", produce(next))
|
||||||
|
cacheProjects()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGlobalStore("project", next)
|
||||||
|
cacheProjects()
|
||||||
|
}
|
||||||
|
|
||||||
|
const setBootStore = ((...input: unknown[]) => {
|
||||||
|
if (input[0] === "project" && Array.isArray(input[1])) {
|
||||||
|
setProjects(input[1] as Project[])
|
||||||
|
return input[1]
|
||||||
|
}
|
||||||
|
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||||
|
}) as typeof setGlobalStore
|
||||||
|
|
||||||
|
const set = ((...input: unknown[]) => {
|
||||||
|
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
|
||||||
|
setProjects(input[1] as Project[] | ((draft: Project[]) => void))
|
||||||
|
return input[1]
|
||||||
|
}
|
||||||
|
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
|
||||||
|
}) as typeof setGlobalStore
|
||||||
|
|
||||||
|
if (projectInit instanceof Promise) {
|
||||||
|
void projectInit.then(() => {
|
||||||
|
if (!active) return
|
||||||
|
if (projectWritten) return
|
||||||
|
const cached = projectCache.value
|
||||||
|
if (cached.length === 0) return
|
||||||
|
setGlobalStore("project", cached)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
if (!todos) {
|
if (!todos) {
|
||||||
|
|
@ -127,30 +175,6 @@ function createGlobalSync() {
|
||||||
return sdk
|
return sdk
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!projectCacheReady()) return
|
|
||||||
if (globalStore.project.length !== 0) return
|
|
||||||
const cached = projectCache.value
|
|
||||||
if (cached.length === 0) return
|
|
||||||
setGlobalStore("project", cached)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!projectCacheReady()) return
|
|
||||||
const projects = globalStore.project
|
|
||||||
if (projects.length === 0) {
|
|
||||||
const cachedLength = untrack(() => projectCache.value.length)
|
|
||||||
if (cachedLength !== 0) return
|
|
||||||
}
|
|
||||||
setProjectCache("value", projects.map(sanitizeProject))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (globalStore.reload !== "complete") return
|
|
||||||
setGlobalStore("reload", undefined)
|
|
||||||
queue.refresh()
|
|
||||||
})
|
|
||||||
|
|
||||||
async function loadSessions(directory: string) {
|
async function loadSessions(directory: string) {
|
||||||
const pending = sessionLoads.get(directory)
|
const pending = sessionLoads.get(directory)
|
||||||
if (pending) return pending
|
if (pending) return pending
|
||||||
|
|
@ -259,13 +283,7 @@ function createGlobalSync() {
|
||||||
event,
|
event,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
refresh: queue.refresh,
|
refresh: queue.refresh,
|
||||||
setGlobalProject(next) {
|
setGlobalProject: setProjects,
|
||||||
if (typeof next === "function") {
|
|
||||||
setGlobalStore("project", produce(next))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setGlobalStore("project", next)
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||||
for (const directory of Object.keys(children.children)) {
|
for (const directory of Object.keys(children.children)) {
|
||||||
|
|
@ -316,7 +334,7 @@ function createGlobalSync() {
|
||||||
unknownError: language.t("error.chain.unknown"),
|
unknownError: language.t("error.chain.unknown"),
|
||||||
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
invalidConfigurationError: language.t("error.server.invalidConfiguration"),
|
||||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||||
setGlobalStore,
|
setGlobalStore: setBootStore,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,7 +358,9 @@ function createGlobalSync() {
|
||||||
.update({ config })
|
.update({ config })
|
||||||
.then(bootstrap)
|
.then(bootstrap)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setGlobalStore("reload", "complete")
|
queue.refresh()
|
||||||
|
setGlobalStore("reload", undefined)
|
||||||
|
queue.refresh()
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
setGlobalStore("reload", undefined)
|
setGlobalStore("reload", undefined)
|
||||||
|
|
@ -350,7 +370,7 @@ function createGlobalSync() {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: globalStore,
|
data: globalStore,
|
||||||
set: setGlobalStore,
|
set,
|
||||||
get ready() {
|
get ready() {
|
||||||
return globalStore.ready
|
return globalStore.ready
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
@ -131,8 +131,7 @@ export function createChildStoreManager(input: {
|
||||||
)
|
)
|
||||||
if (!vcs) throw new Error("Failed to create persisted cache")
|
if (!vcs) throw new Error("Failed to create persisted cache")
|
||||||
const vcsStore = vcs[0]
|
const vcsStore = vcs[0]
|
||||||
const vcsReady = vcs[3]
|
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
|
||||||
vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
|
|
||||||
|
|
||||||
const meta = runWithOwner(input.owner, () =>
|
const meta = runWithOwner(input.owner, () =>
|
||||||
persisted(
|
persisted(
|
||||||
|
|
@ -154,10 +153,12 @@ export function createChildStoreManager(input: {
|
||||||
|
|
||||||
const init = () =>
|
const init = () =>
|
||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
|
const initialMeta = meta[0].value
|
||||||
|
const initialIcon = icon[0].value
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: meta[0].value,
|
projectMeta: initialMeta,
|
||||||
icon: icon[0].value,
|
icon: initialIcon,
|
||||||
provider: { all: [], connected: [], default: {} },
|
provider: { all: [], connected: [], default: {} },
|
||||||
config: {},
|
config: {},
|
||||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||||
|
|
@ -181,16 +182,27 @@ export function createChildStoreManager(input: {
|
||||||
children[directory] = child
|
children[directory] = child
|
||||||
disposers.set(directory, dispose)
|
disposers.set(directory, dispose)
|
||||||
|
|
||||||
createEffect(() => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
if (!vcsReady()) return
|
if (!(init instanceof Promise)) return
|
||||||
|
void init.then(() => {
|
||||||
|
if (children[directory] !== child) return
|
||||||
|
run()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onPersistedInit(vcs[2], () => {
|
||||||
const cached = vcsStore.value
|
const cached = vcsStore.value
|
||||||
if (!cached?.branch) return
|
if (!cached?.branch) return
|
||||||
child[1]("vcs", (value) => value ?? cached)
|
child[1]("vcs", (value) => value ?? cached)
|
||||||
})
|
})
|
||||||
createEffect(() => {
|
|
||||||
|
onPersistedInit(meta[2], () => {
|
||||||
|
if (child[0].projectMeta !== initialMeta) return
|
||||||
child[1]("projectMeta", meta[0].value)
|
child[1]("projectMeta", meta[0].value)
|
||||||
})
|
})
|
||||||
createEffect(() => {
|
|
||||||
|
onPersistedInit(icon[2], () => {
|
||||||
|
if (child[0].icon !== initialIcon) return
|
||||||
child[1]("icon", icon[0].value)
|
child[1]("icon", icon[0].value)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ import { useServer } from "./server"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { Project } from "@opencode-ai/sdk/v2"
|
import { Project } from "@opencode-ai/sdk/v2"
|
||||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||||
|
import { createPathHelpers } from "./file/path"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
const DEFAULT_PANEL_WIDTH = 344
|
const DEFAULT_PANEL_WIDTH = 344
|
||||||
|
|
@ -96,6 +98,38 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
|
||||||
return { all, active: tab }
|
return { all, active: tab }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionPath = (key: string) => {
|
||||||
|
const dir = key.split("/")[0]
|
||||||
|
if (!dir) return
|
||||||
|
const root = decode64(dir)
|
||||||
|
if (!root) return
|
||||||
|
return createPathHelpers(() => root)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
|
||||||
|
if (!tab.startsWith("file://")) return tab
|
||||||
|
if (!path) return tab
|
||||||
|
return path.tab(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return all.flatMap((tab) => {
|
||||||
|
const value = normalizeSessionTab(path, tab)
|
||||||
|
if (seen.has(value)) return []
|
||||||
|
seen.add(value)
|
||||||
|
return [value]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
|
||||||
|
const path = sessionPath(key)
|
||||||
|
return {
|
||||||
|
all: normalizeSessionTabList(path, tabs.all),
|
||||||
|
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
|
||||||
name: "Layout",
|
name: "Layout",
|
||||||
init: () => {
|
init: () => {
|
||||||
|
|
@ -147,12 +181,46 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
|
const sessionTabs = value.sessionTabs
|
||||||
|
const migratedSessionTabs = (() => {
|
||||||
|
if (!isRecord(sessionTabs)) return sessionTabs
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
const next = Object.fromEntries(
|
||||||
|
Object.entries(sessionTabs).map(([key, tabs]) => {
|
||||||
|
if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
|
||||||
|
|
||||||
|
const current = {
|
||||||
|
all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
|
||||||
|
active: typeof tabs.active === "string" ? tabs.active : undefined,
|
||||||
|
}
|
||||||
|
const normalized = normalizeStoredSessionTabs(key, current)
|
||||||
|
if (current.all.length !== tabs.all.length) changed = true
|
||||||
|
if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
|
||||||
|
if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
|
||||||
|
return [key, normalized]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!changed) return sessionTabs
|
||||||
|
return next
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (
|
||||||
|
migratedSidebar === sidebar &&
|
||||||
|
migratedReview === review &&
|
||||||
|
migratedFileTree === fileTree &&
|
||||||
|
migratedSessionTabs === sessionTabs
|
||||||
|
) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
sidebar: migratedSidebar,
|
sidebar: migratedSidebar,
|
||||||
review: migratedReview,
|
review: migratedReview,
|
||||||
fileTree: migratedFileTree,
|
fileTree: migratedFileTree,
|
||||||
|
sessionTabs: migratedSessionTabs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -745,22 +813,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
},
|
},
|
||||||
tabs(sessionKey: string | Accessor<string>) {
|
tabs(sessionKey: string | Accessor<string>) {
|
||||||
const key = createSessionKeyReader(sessionKey, ensureKey)
|
const key = createSessionKeyReader(sessionKey, ensureKey)
|
||||||
|
const path = createMemo(() => sessionPath(key()))
|
||||||
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
|
||||||
|
const normalize = (tab: string) => normalizeSessionTab(path(), tab)
|
||||||
|
const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
|
||||||
return {
|
return {
|
||||||
tabs,
|
tabs,
|
||||||
active: createMemo(() => tabs().active),
|
active: createMemo(() => tabs().active),
|
||||||
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
|
||||||
setActive(tab: string | undefined) {
|
setActive(tab: string | undefined) {
|
||||||
const session = key()
|
const session = key()
|
||||||
|
const next = tab ? normalize(tab) : tab
|
||||||
if (!store.sessionTabs[session]) {
|
if (!store.sessionTabs[session]) {
|
||||||
setStore("sessionTabs", session, { all: [], active: tab })
|
setStore("sessionTabs", session, { all: [], active: next })
|
||||||
} else {
|
} else {
|
||||||
setStore("sessionTabs", session, "active", tab)
|
setStore("sessionTabs", session, "active", next)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setAll(all: string[]) {
|
setAll(all: string[]) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const next = all.filter((tab) => tab !== "review")
|
const next = normalizeAll(all).filter((tab) => tab !== "review")
|
||||||
if (!store.sessionTabs[session]) {
|
if (!store.sessionTabs[session]) {
|
||||||
setStore("sessionTabs", session, { all: next, active: undefined })
|
setStore("sessionTabs", session, { all: next, active: undefined })
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -769,7 +841,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
},
|
},
|
||||||
async open(tab: string) {
|
async open(tab: string) {
|
||||||
const session = key()
|
const session = key()
|
||||||
const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
|
const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
|
||||||
setStore("sessionTabs", session, next)
|
setStore("sessionTabs", session, next)
|
||||||
},
|
},
|
||||||
close(tab: string) {
|
close(tab: string) {
|
||||||
|
|
|
||||||
|
|
@ -59,11 +59,11 @@ import { useLanguage, type Locale } from "@/context/language"
|
||||||
import {
|
import {
|
||||||
childMapByParent,
|
childMapByParent,
|
||||||
displayName,
|
displayName,
|
||||||
|
effectiveWorkspaceOrder,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
getDraggableId,
|
getDraggableId,
|
||||||
latestRootSession,
|
latestRootSession,
|
||||||
sortedRootSessions,
|
sortedRootSessions,
|
||||||
syncWorkspaceOrder,
|
|
||||||
workspaceKey,
|
workspaceKey,
|
||||||
} from "./layout/helpers"
|
} from "./layout/helpers"
|
||||||
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
|
import { collectOpenProjectDeepLinks, deepLinkEvent, drainPendingDeepLinks } from "./layout/deep-links"
|
||||||
|
|
@ -481,21 +481,6 @@ export default function Layout(props: ParentProps) {
|
||||||
return projects.find((p) => p.worktree === root)
|
return projects.find((p) => p.worktree === root)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => ({ ready: pageReady(), project: currentProject() }),
|
|
||||||
(value) => {
|
|
||||||
if (!value.ready) return
|
|
||||||
const project = value.project
|
|
||||||
if (!project) return
|
|
||||||
const last = server.projects.last()
|
|
||||||
if (last === project.worktree) return
|
|
||||||
server.projects.touch(project.worktree)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
|
() => ({ ready: pageReady(), layoutReady: layoutReady(), dir: params.dir, list: layout.projects.list() }),
|
||||||
|
|
@ -554,29 +539,17 @@ export default function Layout(props: ParentProps) {
|
||||||
return layout.sidebar.workspaces(project.worktree)()
|
return layout.sidebar.workspaces(project.worktree)()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
const visibleSessionDirs = createMemo(() => {
|
||||||
if (!pageReady()) return
|
|
||||||
if (!layoutReady()) return
|
|
||||||
const project = currentProject()
|
const project = currentProject()
|
||||||
if (!project) return
|
if (!project) return [] as string[]
|
||||||
|
if (!workspaceSetting()) return [project.worktree]
|
||||||
|
|
||||||
const local = project.worktree
|
const activeDir = currentDir()
|
||||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
return workspaceIds(project).filter((directory) => {
|
||||||
const existing = store.workspaceOrder[project.worktree]
|
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||||
const merged = syncWorkspaceOrder(local, dirs, existing)
|
const active = directory === activeDir
|
||||||
if (!existing) {
|
return expanded || active
|
||||||
setStore("workspaceOrder", project.worktree, merged)
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (merged.length !== existing.length) {
|
|
||||||
setStore("workspaceOrder", project.worktree, merged)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (merged.some((d, i) => d !== existing[i])) {
|
|
||||||
setStore("workspaceOrder", project.worktree, merged)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -593,25 +566,17 @@ export default function Layout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentSessions = createMemo(() => {
|
const currentSessions = createMemo(() => {
|
||||||
const project = currentProject()
|
|
||||||
if (!project) return [] as Session[]
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (workspaceSetting()) {
|
const dirs = visibleSessionDirs()
|
||||||
const dirs = workspaceIds(project)
|
if (dirs.length === 0) return [] as Session[]
|
||||||
const activeDir = currentDir()
|
|
||||||
const result: Session[] = []
|
const result: Session[] = []
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
|
|
||||||
const active = dir === activeDir
|
|
||||||
if (!expanded && !active) continue
|
|
||||||
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
||||||
const dirSessions = sortedRootSessions(dirStore, now)
|
const dirSessions = sortedRootSessions(dirStore, now)
|
||||||
result.push(...dirSessions)
|
result.push(...dirSessions)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
|
||||||
const [projectStore] = globalSync.child(project.worktree)
|
|
||||||
return sortedRootSessions(projectStore, now)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
type PrefetchQueue = {
|
type PrefetchQueue = {
|
||||||
|
|
@ -826,7 +791,6 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigateSessionByUnseen(offset: number) {
|
function navigateSessionByUnseen(offset: number) {
|
||||||
|
|
@ -861,7 +825,6 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToSession(session)
|
navigateToSession(session)
|
||||||
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1094,34 +1057,90 @@ export default function Layout(props: ParentProps) {
|
||||||
return meta?.worktree ?? directory
|
return meta?.worktree ?? directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activeProjectRoot(directory: string) {
|
||||||
|
return currentProject()?.worktree ?? projectRoot(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchProjectRoute() {
|
||||||
|
const root = currentProject()?.worktree
|
||||||
|
if (!root) return
|
||||||
|
if (server.projects.last() !== root) server.projects.touch(root)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
||||||
|
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLastProjectSession(root: string) {
|
||||||
|
if (!store.lastProjectSession[root]) return
|
||||||
|
setStore(
|
||||||
|
"lastProjectSession",
|
||||||
|
produce((draft) => {
|
||||||
|
delete draft[root]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
|
||||||
|
rememberSessionRoute(directory, id, root)
|
||||||
|
notification.session.markViewed(id)
|
||||||
|
const expanded = untrack(() => store.workspaceExpanded[directory])
|
||||||
|
if (expanded === false) {
|
||||||
|
setStore("workspaceExpanded", directory, true)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
async function navigateToProject(directory: string | undefined) {
|
async function navigateToProject(directory: string | undefined) {
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const root = projectRoot(directory)
|
const root = projectRoot(directory)
|
||||||
server.projects.touch(root)
|
server.projects.touch(root)
|
||||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||||
const dirs = Array.from(new Set([root, ...(store.workspaceOrder[root] ?? []), ...(project?.sandboxes ?? [])]))
|
let dirs = project
|
||||||
|
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
|
||||||
|
: [root]
|
||||||
|
const canOpen = (value: string | undefined) => {
|
||||||
|
if (!value) return false
|
||||||
|
return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
|
||||||
|
}
|
||||||
|
const refreshDirs = async (target?: string) => {
|
||||||
|
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||||
|
const listed = await globalSDK.client.worktree
|
||||||
|
.list({ directory: root })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
|
||||||
|
return canOpen(target)
|
||||||
|
}
|
||||||
const openSession = async (target: { directory: string; id: string }) => {
|
const openSession = async (target: { directory: string; id: string }) => {
|
||||||
|
if (!canOpen(target.directory)) return false
|
||||||
const resolved = await globalSDK.client.session
|
const resolved = await globalSDK.client.session
|
||||||
.get({ sessionID: target.id })
|
.get({ sessionID: target.id })
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
const next = resolved?.directory ? resolved : target
|
if (!resolved?.directory) return false
|
||||||
setStore("lastProjectSession", root, { directory: next.directory, id: next.id, at: Date.now() })
|
if (!canOpen(resolved.directory)) return false
|
||||||
navigateWithSidebarReset(`/${base64Encode(next.directory)}/session/${next.id}`)
|
setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectSession = store.lastProjectSession[root]
|
const projectSession = store.lastProjectSession[root]
|
||||||
if (projectSession?.id) {
|
if (projectSession?.id) {
|
||||||
await openSession(projectSession)
|
await refreshDirs(projectSession.directory)
|
||||||
return
|
const opened = await openSession(projectSession)
|
||||||
|
if (opened) return
|
||||||
|
clearLastProjectSession(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = latestRootSession(
|
const latest = latestRootSession(
|
||||||
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
|
dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
)
|
)
|
||||||
if (latest) {
|
if (latest && (await openSession(latest))) {
|
||||||
await openSession(latest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1137,8 +1156,7 @@ export default function Layout(props: ParentProps) {
|
||||||
),
|
),
|
||||||
Date.now(),
|
Date.now(),
|
||||||
)
|
)
|
||||||
if (fetched) {
|
if (fetched && (await openSession(fetched))) {
|
||||||
await openSession(fetched)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1240,9 +1258,17 @@ export default function Layout(props: ParentProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteWorkspace = async (root: string, directory: string) => {
|
const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
|
||||||
if (directory === root) return
|
if (directory === root) return
|
||||||
|
|
||||||
|
const current = currentDir()
|
||||||
|
const currentKey = workspaceKey(current)
|
||||||
|
const deletedKey = workspaceKey(directory)
|
||||||
|
const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
|
||||||
|
if (!leaveDeletedWorkspace && shouldLeave) {
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||||
|
}
|
||||||
|
|
||||||
setBusy(directory, true)
|
setBusy(directory, true)
|
||||||
|
|
||||||
const result = await globalSDK.client.worktree
|
const result = await globalSDK.client.worktree
|
||||||
|
|
@ -1260,6 +1286,10 @@ export default function Layout(props: ParentProps) {
|
||||||
|
|
||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
|
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
|
||||||
|
clearLastProjectSession(root)
|
||||||
|
}
|
||||||
|
|
||||||
globalSync.set(
|
globalSync.set(
|
||||||
"project",
|
"project",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|
@ -1273,8 +1303,18 @@ export default function Layout(props: ParentProps) {
|
||||||
layout.projects.close(directory)
|
layout.projects.close(directory)
|
||||||
layout.projects.open(root)
|
layout.projects.open(root)
|
||||||
|
|
||||||
if (params.dir && currentDir() === directory) {
|
if (shouldLeave) return
|
||||||
navigateToProject(root)
|
|
||||||
|
const nextCurrent = currentDir()
|
||||||
|
const nextKey = workspaceKey(nextCurrent)
|
||||||
|
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||||
|
const dirs = project
|
||||||
|
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
|
||||||
|
: [root]
|
||||||
|
const valid = dirs.some((item) => workspaceKey(item) === nextKey)
|
||||||
|
|
||||||
|
if (params.dir && projectRoot(nextCurrent) === root && !valid) {
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1377,8 +1417,12 @@ export default function Layout(props: ParentProps) {
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
|
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
|
||||||
|
if (leaveDeletedWorkspace) {
|
||||||
|
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
|
||||||
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
void deleteWorkspace(props.root, props.directory)
|
void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
|
||||||
}
|
}
|
||||||
|
|
||||||
const description = () => {
|
const description = () => {
|
||||||
|
|
@ -1486,26 +1530,42 @@ export default function Layout(props: ParentProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeRoute = {
|
||||||
|
session: "",
|
||||||
|
sessionProject: "",
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ ready: pageReady(), dir: params.dir, id: params.id }),
|
() => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
|
||||||
(value) => {
|
([ready, dir, id]) => {
|
||||||
if (!value.ready) return
|
if (!ready || !dir) {
|
||||||
const dir = value.dir
|
activeRoute.session = ""
|
||||||
const id = value.id
|
activeRoute.sessionProject = ""
|
||||||
if (!dir || !id) return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const directory = decode64(dir)
|
const directory = decode64(dir)
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const at = Date.now()
|
|
||||||
setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
|
const root = touchProjectRoute() ?? activeProjectRoot(directory)
|
||||||
notification.session.markViewed(id)
|
|
||||||
const expanded = untrack(() => store.workspaceExpanded[directory])
|
if (!id) {
|
||||||
if (expanded === false) {
|
activeRoute.session = ""
|
||||||
setStore("workspaceExpanded", directory, true)
|
activeRoute.sessionProject = ""
|
||||||
|
return
|
||||||
}
|
}
|
||||||
requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
|
|
||||||
|
const session = `${dir}/${id}`
|
||||||
|
if (session !== activeRoute.session) {
|
||||||
|
activeRoute.session = session
|
||||||
|
activeRoute.sessionProject = syncSessionRoute(directory, id, root)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root === activeRoute.sessionProject) return
|
||||||
|
activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
|
||||||
},
|
},
|
||||||
{ defer: true },
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1516,30 +1576,16 @@ export default function Layout(props: ParentProps) {
|
||||||
|
|
||||||
const loadedSessionDirs = new Set<string>()
|
const loadedSessionDirs = new Set<string>()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
const project = currentProject()
|
on(
|
||||||
const workspaces = workspaceSetting()
|
visibleSessionDirs,
|
||||||
const next = new Set<string>()
|
(dirs) => {
|
||||||
if (!project) {
|
if (dirs.length === 0) {
|
||||||
loadedSessionDirs.clear()
|
loadedSessionDirs.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (workspaces) {
|
const next = new Set(dirs)
|
||||||
const activeDir = currentDir()
|
|
||||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
|
||||||
for (const directory of dirs) {
|
|
||||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
|
||||||
const active = directory === activeDir
|
|
||||||
if (!expanded && !active) continue
|
|
||||||
next.add(directory)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!workspaces) {
|
|
||||||
next.add(project.worktree)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const directory of next) {
|
for (const directory of next) {
|
||||||
if (loadedSessionDirs.has(directory)) continue
|
if (loadedSessionDirs.has(directory)) continue
|
||||||
globalSync.project.loadSessions(directory)
|
globalSync.project.loadSessions(directory)
|
||||||
|
|
@ -1549,7 +1595,10 @@ export default function Layout(props: ParentProps) {
|
||||||
for (const directory of next) {
|
for (const directory of next) {
|
||||||
loadedSessionDirs.add(directory)
|
loadedSessionDirs.add(directory)
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
function handleDragStart(event: unknown) {
|
function handleDragStart(event: unknown) {
|
||||||
const id = getDraggableId(event)
|
const id = getDraggableId(event)
|
||||||
|
|
@ -1583,14 +1632,11 @@ export default function Layout(props: ParentProps) {
|
||||||
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
|
const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
|
||||||
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
||||||
|
|
||||||
const existing = store.workspaceOrder[project.worktree]
|
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
|
||||||
if (!existing) return extra ? [...dirs, extra] : dirs
|
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
|
||||||
|
if (!extra) return ordered
|
||||||
const merged = syncWorkspaceOrder(local, dirs, existing)
|
if (pending) return ordered
|
||||||
if (pending && extra) return [local, extra, ...merged.filter((directory) => directory !== local)]
|
return [...ordered, extra]
|
||||||
if (!extra) return merged
|
|
||||||
if (pending) return merged
|
|
||||||
return [...merged, extra]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sidebarProject = createMemo(() => {
|
const sidebarProject = createMemo(() => {
|
||||||
|
|
@ -1623,7 +1669,11 @@ export default function Layout(props: ParentProps) {
|
||||||
const [item] = result.splice(fromIndex, 1)
|
const [item] = result.splice(fromIndex, 1)
|
||||||
if (!item) return
|
if (!item) return
|
||||||
result.splice(toIndex, 0, item)
|
result.splice(toIndex, 0, item)
|
||||||
setStore("workspaceOrder", project.worktree, result)
|
setStore(
|
||||||
|
"workspaceOrder",
|
||||||
|
project.worktree,
|
||||||
|
result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleWorkspaceDragEnd() {
|
function handleWorkspaceDragEnd() {
|
||||||
|
|
@ -1661,10 +1711,9 @@ export default function Layout(props: ParentProps) {
|
||||||
const existing = prev ?? []
|
const existing = prev ?? []
|
||||||
const next = existing.filter((item) => {
|
const next = existing.filter((item) => {
|
||||||
const id = workspaceKey(item)
|
const id = workspaceKey(item)
|
||||||
if (id === root) return false
|
return id !== root && id !== key
|
||||||
return id !== key
|
|
||||||
})
|
})
|
||||||
return [local, created.directory, ...next]
|
return [created.directory, ...next]
|
||||||
})
|
})
|
||||||
|
|
||||||
globalSync.child(created.directory)
|
globalSync.child(created.directory)
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,29 @@ export const errorMessage = (err: unknown, fallback: string) => {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
|
export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
|
||||||
if (!existing) return dirs
|
const root = workspaceKey(local)
|
||||||
const keep = existing.filter((d) => d !== local && dirs.includes(d))
|
const live = new Map<string, string>()
|
||||||
const missing = dirs.filter((d) => d !== local && !existing.includes(d))
|
|
||||||
return [local, ...missing, ...keep]
|
for (const dir of dirs) {
|
||||||
|
const key = workspaceKey(dir)
|
||||||
|
if (key === root) continue
|
||||||
|
if (!live.has(key)) live.set(key, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!persisted?.length) return [local, ...live.values()]
|
||||||
|
|
||||||
|
const result = [local]
|
||||||
|
for (const dir of persisted) {
|
||||||
|
const key = workspaceKey(dir)
|
||||||
|
if (key === root) continue
|
||||||
|
const match = live.get(key)
|
||||||
|
if (!match) continue
|
||||||
|
result.push(match)
|
||||||
|
live.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...result, ...live.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const syncWorkspaceOrder = effectiveWorkspaceOrder
|
||||||
|
|
|
||||||
|
|
@ -347,24 +347,6 @@ export default function Page() {
|
||||||
if (path) file.load(path)
|
if (path) file.load(path)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const current = tabs().all()
|
|
||||||
if (current.length === 0) return
|
|
||||||
|
|
||||||
const next = normalizeTabs(current)
|
|
||||||
if (same(current, next)) return
|
|
||||||
|
|
||||||
tabs().setAll(next)
|
|
||||||
|
|
||||||
const active = tabs().active()
|
|
||||||
if (!active) return
|
|
||||||
if (!active.startsWith("file://")) return
|
|
||||||
|
|
||||||
const normalized = normalizeTab(active)
|
|
||||||
if (active === normalized) return
|
|
||||||
tabs().setActive(normalized)
|
|
||||||
})
|
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
|
|
||||||
let scroll: HTMLDivElement | undefined
|
let scroll: HTMLDivElement | undefined
|
||||||
let scrollFrame: number | undefined
|
let scrollFrame: number | undefined
|
||||||
|
let restoreFrame: number | undefined
|
||||||
let pending: { x: number; y: number } | undefined
|
let pending: { x: number; y: number } | undefined
|
||||||
let codeScroll: HTMLElement[] = []
|
let codeScroll: HTMLElement[] = []
|
||||||
let find: FileSearchHandle | null = null
|
let find: FileSearchHandle | null = null
|
||||||
|
|
@ -349,6 +350,15 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queueRestore = () => {
|
||||||
|
if (restoreFrame !== undefined) return
|
||||||
|
|
||||||
|
restoreFrame = requestAnimationFrame(() => {
|
||||||
|
restoreFrame = undefined
|
||||||
|
restoreScroll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||||
if (codeScroll.length === 0) syncCodeScroll()
|
if (codeScroll.length === 0) syncCodeScroll()
|
||||||
|
|
||||||
|
|
@ -364,46 +374,29 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
setNote("commenting", null)
|
setNote("commenting", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
let prev = {
|
||||||
on(
|
loaded: false,
|
||||||
() => state()?.loaded,
|
ready: false,
|
||||||
(loaded) => {
|
active: false,
|
||||||
if (!loaded) return
|
}
|
||||||
requestAnimationFrame(restoreScroll)
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
createEffect(() => {
|
||||||
on(
|
const loaded = !!state()?.loaded
|
||||||
() => file.ready(),
|
const ready = file.ready()
|
||||||
(ready) => {
|
const active = tabs().active() === props.tab
|
||||||
if (!ready) return
|
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
||||||
requestAnimationFrame(restoreScroll)
|
prev = { loaded, ready, active }
|
||||||
},
|
if (!restore) return
|
||||||
{ defer: true },
|
queueRestore()
|
||||||
),
|
})
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => tabs().active() === props.tab,
|
|
||||||
(active) => {
|
|
||||||
if (!active) return
|
|
||||||
if (!state()?.loaded) return
|
|
||||||
requestAnimationFrame(restoreScroll)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
for (const item of codeScroll) {
|
for (const item of codeScroll) {
|
||||||
item.removeEventListener("scroll", handleCodeScroll)
|
item.removeEventListener("scroll", handleCodeScroll)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scrollFrame === undefined) return
|
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||||
cancelAnimationFrame(scrollFrame)
|
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderFile = (source: string) => (
|
const renderFile = (source: string) => (
|
||||||
|
|
@ -421,7 +414,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
selectedLines={activeSelection()}
|
selectedLines={activeSelection()}
|
||||||
commentedLines={commentedLines()}
|
commentedLines={commentedLines()}
|
||||||
onRendered={() => {
|
onRendered={() => {
|
||||||
requestAnimationFrame(restoreScroll)
|
queueRestore()
|
||||||
}}
|
}}
|
||||||
annotations={commentsUi.annotations()}
|
annotations={commentsUi.annotations()}
|
||||||
renderAnnotation={commentsUi.renderAnnotation}
|
renderAnnotation={commentsUi.renderAnnotation}
|
||||||
|
|
@ -440,7 +433,7 @@ export function FileTabContent(props: { tab: string }) {
|
||||||
mode: "auto",
|
mode: "auto",
|
||||||
path: path(),
|
path: path(),
|
||||||
current: state()?.content,
|
current: state()?.content,
|
||||||
onLoad: () => requestAnimationFrame(restoreScroll),
|
onLoad: queueRestore,
|
||||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||||
if (args.kind !== "svg") return
|
if (args.kind !== "svg") return
|
||||||
showToast({
|
showToast({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createEffect, on, onCleanup, type JSX } from "solid-js"
|
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -119,32 +119,12 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(() => {
|
||||||
on(
|
props.diffs().length
|
||||||
() => props.diffs().length,
|
props.diffStyle
|
||||||
() => queueRestore(),
|
if (!layout.ready()) return
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => props.diffStyle,
|
|
||||||
() => queueRestore(),
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => layout.ready(),
|
|
||||||
(ready) => {
|
|
||||||
if (!ready) return
|
|
||||||
queueRestore()
|
queueRestore()
|
||||||
},
|
})
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ export function TerminalPanel() {
|
||||||
on(
|
on(
|
||||||
() => terminal.all().length,
|
() => terminal.all().length,
|
||||||
(count, prevCount) => {
|
(count, prevCount) => {
|
||||||
if (prevCount !== undefined && prevCount > 0 && count === 0) {
|
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
|
||||||
if (opened()) view().terminal.toggle()
|
if (!opened()) return
|
||||||
}
|
close()
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
export const messageIdFromHash = (hash: string) => {
|
export const messageIdFromHash = (hash: string) => {
|
||||||
|
|
@ -28,6 +28,7 @@ export const useSessionHashScroll = (input: {
|
||||||
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
|
const visibleUserMessages = createMemo(() => input.visibleUserMessages())
|
||||||
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m])))
|
||||||
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
||||||
|
let pendingKey = ""
|
||||||
|
|
||||||
const clearMessageHash = () => {
|
const clearMessageHash = () => {
|
||||||
if (!window.location.hash) return
|
if (!window.location.hash) return
|
||||||
|
|
@ -130,15 +131,6 @@ export const useSessionHashScroll = (input: {
|
||||||
if (el) input.scheduleScrollState(el)
|
if (el) input.scheduleScrollState(el)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(input.sessionKey, (key) => {
|
|
||||||
if (!input.sessionID()) return
|
|
||||||
const messageID = input.consumePendingMessage(key)
|
|
||||||
if (!messageID) return
|
|
||||||
input.setPendingMessage(messageID)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!input.sessionID() || !input.messagesReady()) return
|
if (!input.sessionID() || !input.messagesReady()) return
|
||||||
requestAnimationFrame(() => applyHash("auto"))
|
requestAnimationFrame(() => applyHash("auto"))
|
||||||
|
|
@ -150,7 +142,20 @@ export const useSessionHashScroll = (input: {
|
||||||
visibleUserMessages()
|
visibleUserMessages()
|
||||||
input.turnStart()
|
input.turnStart()
|
||||||
|
|
||||||
const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
|
let targetId = input.pendingMessage()
|
||||||
|
if (!targetId) {
|
||||||
|
const key = input.sessionKey()
|
||||||
|
if (pendingKey !== key) {
|
||||||
|
pendingKey = key
|
||||||
|
const next = input.consumePendingMessage(key)
|
||||||
|
if (next) {
|
||||||
|
input.setPendingMessage(next)
|
||||||
|
targetId = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetId) targetId = messageIdFromHash(window.location.hash)
|
||||||
if (!targetId) return
|
if (!targetId) return
|
||||||
if (input.currentMessageId() === targetId) return
|
if (input.currentMessageId() === targetId) return
|
||||||
|
|
||||||
|
|
@ -162,9 +167,12 @@ export const useSessionHashScroll = (input: {
|
||||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
onMount(() => {
|
||||||
|
const handler = () => {
|
||||||
if (!input.sessionID() || !input.messagesReady()) return
|
if (!input.sessionID() || !input.messagesReady()) return
|
||||||
const handler = () => requestAnimationFrame(() => applyHash("auto"))
|
requestAnimationFrame(() => applyHash("auto"))
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener("hashchange", handler)
|
window.addEventListener("hashchange", handler)
|
||||||
onCleanup(() => window.removeEventListener("hashchange", handler))
|
onCleanup(() => window.removeEventListener("hashchange", handler))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue