Skip to content

fix(desktop): v2 Open In editor is per-project, not per-workspace - #3393

Merged
saddlepaddle merged 2 commits into
mainfrom
file-editor-time
Apr 13, 2026
Merged

saddlepaddle merged 2 commits into
mainfrom
file-editor-time

Conversation

@saddlepaddle

@saddlepaddle saddlepaddle commented Apr 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • v2 top-bar "Open In " picker was writing to v2WorkspaceLocalState.defaultOpenInApp (per-workspace localStorage), so switching workspaces inside the same project silently reset the picker.
  • Move the setting to the per-project v2SidebarProjects collection: read reactively via useLiveQuery, persist via collections.v2SidebarProjects.update(projectId, ...) with a defensive insert fallback. The choice is now shared across every workspace in a project.
  • Plumb projectId through V2WorkspaceOpenInButton and drop the now-unused workspaceId prop. openInApp mutations no longer pass projectId, so persistence lives entirely on the client-side react-db collection and the server-side projects.defaultApp SQLite column is left alone.
  • Remove the dead defaultOpenInApp field from workspaceLocalStateSchema. Stale values in existing localStorage entries are silently dropped by Zod on next parse.

Test plan

  • bun run lint
  • bunx turbo typecheck --filter @superset/desktop
  • bunx turbo test --filter @superset/desktop (1593 pass)
  • Manual: pick Cursor in workspace A of project X → switch to workspace B in the same project → picker still shows Cursor.
  • Manual: switch to a workspace in a different project Y that has never had a choice made → picker falls back to Finder / the global seed.
  • Manual: quit and relaunch → per-project choice persists.

Note: v2 file-tree / diff-pane clicks still resolve openFileInEditor through resolveDefaultEditor(projectId) in the main process, which reads the SQLite projects.defaultApp column (now unwritten by the v2 path) and falls back to settings.defaultEditor. Making those call sites honor the per-project react-db choice is a follow-up.


Summary by cubic

Make the v2 top-bar “Open In ” setting per-project, so the choice persists across workspaces in the same project. Persistence lives in the per-project sidebar collection and uses the sidebar state helper to create rows correctly.

  • Bug Fixes
    • Persist selection in v2SidebarProjects (read via useLiveQuery); call ensureProjectInSidebar before update(...) to avoid phantom rows and keep tab order.
    • Pass projectId to V2WorkspaceOpenInButton/V2OpenInMenuButton and remove workspaceId; do not send projectId to openInApp.
    • Add defaultOpenInApp to dashboardSidebarProjectSchema; remove it from workspaceLocalStateSchema and ignore stale localStorage values.

Written for commit d2722b8. Summary will update on new commits.

Summary by CodeRabbit

  • Improvements
    • Default "Open In" preference is now scoped per project instead of per workspace.
    • Project-level preference is persisted live to the database so changes are retained across sessions.
    • UI and shortcuts now respect the resolved project preference with a sensible fallback when none is set.

The v2 top-bar "Open In <editor>" dropdown was storing the choice in
the per-workspace localStorage collection, so switching between
workspaces in the same project reset the picker. Move it to the
per-project v2SidebarProjects collection so the choice is shared
across every workspace in a project. Also drops the unused
workspaceId prop and no longer passes projectId to openInApp, since
persistence now happens entirely client-side on the react-db
collection.
@coderabbitai

coderabbitai Bot commented Apr 13, 2026 •

Copy link
Copy Markdown
Contributor

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7ebe308d-65ed-4b5e-9afc-5ffa4a9bdf17

📥 Commits

Reviewing files that changed from the base of the PR and between e55bf5f and d2722b8.

📒 Files selected for processing (1)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx

📝 Walkthrough

Walkthrough

Migrates the default "open in" preference from workspace-local React state to a per-project persisted field, renames component prop from workspaceId to projectId, and replaces local state updates with live DB queries and upsert persistence.

Changes

Cohort / File(s) Summary
Menu Button Components
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx, apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx
Renamed prop workspaceId → projectId; replaced local state usage of defaultOpenInApp with useLiveQuery against collections.v2SidebarProjects (computed resolvedApp); removed local updater and added persistDefaultApp which ensures sidebar project and upserts defaultOpenInApp; updated all references to use resolvedApp.
Database Schema
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts
Added defaultOpenInApp: z.string().nullable().default(null) to dashboardSidebarProjectSchema; removed defaultOpenInApp from workspaceLocalStateSchema, shifting the field to project-level persistence.

Sequence Diagram(s)

sequenceDiagram
  participant User as "User"
  participant UI as "V2OpenInMenuButton"
  participant Sidebar as "Sidebar Projects (DB)"
  participant Ensurer as "ensureProjectInSidebar"
  User->>UI: Trigger open-in (click/hotkey)
  UI->>Sidebar: useLiveQuery(projectId) — read defaultOpenInApp
  Sidebar-->>UI: return defaultOpenInApp (resolvedApp)
  UI->>UI: compute currentApp / payload with resolvedApp
  UI->>Sidebar: openInApp.mutate(...)
  UI->>Ensurer: persistDefaultApp -> ensure project exists
  Ensurer->>Sidebar: upsert defaultOpenInApp for projectId
  Sidebar-->>UI: upsert success
  UI-->>User: open-in action completed
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐇 A nibble, a hop, prefs find their plot,

From transient leaves to a project's spot.
Live queries hum as upserts sing,
The button knows where to open—spring! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: moving the v2 Open In editor setting from per-workspace to per-project scope.
Description check ✅ Passed The description comprehensively covers the changes, related issues context, type of change (bug fix), testing steps, and additional notes about follow-up work.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch file-editor-time

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a scoping bug where the "Open In <editor>" picker in the v2 desktop top-bar was persisting its choice to the per-workspace v2WorkspaceLocalState collection, causing it to silently reset whenever the user switched workspaces within the same project. The fix moves persistence to the per-project v2SidebarProjects collection (keyed by projectId), so the selected editor is shared across all workspaces in a project and survives workspace switches.

Key changes:

  • V2OpenInMenuButton now receives projectId and uses useLiveQuery on v2SidebarProjects to reactively reflect the stored choice.
  • persistDefaultApp calls .update() if a record already exists, or .insert() with schema-default values as a defensive fallback if no record exists yet.
  • V2WorkspaceOpenInButton extracts workspace.projectId from the live workspace query and passes it down to V2OpenInMenuButton.
  • The dead defaultOpenInApp field is removed from workspaceLocalStateSchema; Zod will silently strip stale values from existing localStorage entries on next parse.

The one noteworthy concern is the defensive insert fallback in persistDefaultApp — it creates a v2SidebarProjects entry with tabOrder: 0 for projects that have no existing sidebar record, which could affect sidebar ordering or block the sidebar's own insert for the same key.

Confidence Score: 4/5

Safe to merge after reviewing the insert-fallback behaviour in persistDefaultApp; no data-loss or security risk.

The core scoping fix is correct and well-implemented: reading via useLiveQuery from v2SidebarProjects keyed by projectId is the right reactive pattern, and removing the dead defaultOpenInApp field from workspaceLocalStateSchema is clean. The one P1 concern is the defensive insert in persistDefaultApp which can create a v2SidebarProjects entry with tabOrder: 0 for projects that have no existing sidebar record, potentially affecting sidebar ordering or blocking the sidebar's own insert later. If the team is confident this path is not exercised in normal usage, the PR is ready to merge; otherwise a small guard is warranted.

apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx — the persistDefaultApp insert fallback (lines 57–64).

Important Files Changed

Filename Overview
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx Core change: reads/writes defaultOpenInApp from v2SidebarProjects via useLiveQuery and a .get()/.update()/.insert() fallback; the defensive insert creates a record with tabOrder: 0 defaults which could conflict with sidebar-managed ordering for previously-unseen projects.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2WorkspaceOpenInButton/V2WorkspaceOpenInButton.tsx Straightforward adapter: extracts workspace.projectId from the live workspace query and passes it to V2OpenInMenuButton, replacing the old per-workspace key; no logic issues.
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts Adds defaultOpenInApp: z.string().nullable().default(null) to dashboardSidebarProjectSchema and removes the same field from workspaceLocalStateSchema; Zod's strip-unknown behaviour handles migration of stale localStorage entries cleanly.

Sequence Diagram

sequenceDiagram
    participant User
    participant V2WorkspaceOpenInButton
    participant v2Workspaces as v2Workspaces Collection
    participant V2OpenInMenuButton
    participant v2SidebarProjects as v2SidebarProjects Collection
    participant ElectronTRPC as electronTrpc (IPC)

    User->>V2WorkspaceOpenInButton: render (workspaceId prop)
    V2WorkspaceOpenInButton->>v2Workspaces: useLiveQuery(workspaceId)
    v2Workspaces-->>V2WorkspaceOpenInButton: { branch, projectId, hostMachineId }
    V2WorkspaceOpenInButton->>V2OpenInMenuButton: render(branch, worktreePath, projectId)

    V2OpenInMenuButton->>v2SidebarProjects: useLiveQuery(projectId)
    v2SidebarProjects-->>V2OpenInMenuButton: { defaultOpenInApp } or []

    Note over V2OpenInMenuButton: resolvedApp = defaultOpenInApp ?? "finder"

    User->>V2OpenInMenuButton: clicks Open or picks app from dropdown
    V2OpenInMenuButton->>ElectronTRPC: openInApp.mutate({ path, app })
    ElectronTRPC-->>V2OpenInMenuButton: onSuccess(app)

    alt project record exists
        V2OpenInMenuButton->>v2SidebarProjects: .update(projectId, draft.defaultOpenInApp = app)
    else no project record yet
        V2OpenInMenuButton->>v2SidebarProjects: .insert({ projectId, defaultOpenInApp: app, tabOrder: 0 })
    end

    v2SidebarProjects-->>V2OpenInMenuButton: re-render via useLiveQuery
    Note over V2OpenInMenuButton: picker now shows newly-chosen app
Loading

Reviews (1): Last reviewed commit: "fix(desktop): v2 Open In editor is per-p..." | Re-trigger Greptile

Comment on lines +49 to 66
const persistDefaultApp = useCallback(
(app: ExternalApp) => {
setDefaultApp(app);
collections.v2WorkspaceLocalState.update(workspaceId, (draft) => {
draft.defaultOpenInApp = app;
if (collections.v2SidebarProjects.get(projectId)) {
collections.v2SidebarProjects.update(projectId, (draft) => {
draft.defaultOpenInApp = app;
});
return;
}
collections.v2SidebarProjects.insert({
projectId,
createdAt: new Date(),
tabOrder: 0,
isCollapsed: false,
defaultOpenInApp: app,
});
},
[collections, workspaceId],
[collections, projectId],
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Defensive insert can create a phantom sidebar-project entry

When no v2SidebarProjects record exists for projectId (e.g. the user navigated directly to a workspace URL without ever opening the project in the sidebar), the fallback insert creates a record with tabOrder: 0 and isCollapsed: false. Because the v2SidebarProjects collection is keyed by projectId, this synthetic entry will be visible to every component that reads the collection — including the sidebar renderer. Depending on how the sidebar orders and filters its project list, this could:

  1. Surface a "ghost" project row with tabOrder: 0 (likely sorting it to the very top).
  2. Block the sidebar's own insert when it later tries to create its canonical entry for the same projectId, potentially swallowing the error silently or producing an unexpected no-op.

A safer approach would be to scope this field write so it only mutates an already-existing record, and treats a missing record as "no preference stored yet":

const persistDefaultApp = useCallback(
    (app: ExternalApp) => {
        if (collections.v2SidebarProjects.get(projectId)) {
            collections.v2SidebarProjects.update(projectId, (draft) => {
                draft.defaultOpenInApp = app;
            });
        }
        // No insert fallback: the sidebar manages the authoritative record.
    },
    [collections, projectId],
);

If the intent is genuinely to persist the choice before the sidebar creates its record, the insertion should use the sidebar's own "create project entry" logic so that tabOrder is computed correctly rather than hard-coded to 0.

Comment on lines +46 to +47
const resolvedApp: ExternalApp =
(sidebarProjectRows[0]?.defaultOpenInApp as ExternalApp | null) ?? "finder";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unsafe cast from string | null to ExternalApp

defaultOpenInApp is typed as string | null in the schema, but it is cast directly to ExternalApp | null here. If a stale or manually-edited localStorage entry contains a string that is not a member of the ExternalApp union, resolvedApp will silently carry an invalid value. getAppOption(resolvedApp) does guard against this (returning null → currentApp is null → the button shows "Select an editor from the dropdown"), so there is no crash, but the mismatch between the declared type and the runtime value could hide bugs in callers that trust the type annotation.

Consider validating against the known ExternalApp values before the cast, or narrowing the schema to an enum so Zod rejects unknown strings at parse time:

const rawApp = sidebarProjectRows[0]?.defaultOpenInApp ?? null;
const resolvedApp: ExternalApp = (isExternalApp(rawApp) ? rawApp : null) ?? "finder";

where isExternalApp is a simple type-guard against the ExternalApp union.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx">

<violation number="1" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx:57">
P2: This fallback `insert` creates a `v2SidebarProjects` record with hard-coded `tabOrder: 0` and `isCollapsed: false`. Since this collection is read by the sidebar renderer, this can surface a phantom project row (likely sorted to the top) and may conflict with the sidebar's own insert logic for the same `projectId`. Consider only updating existing records here and skipping the insert entirely — let the sidebar manage authoritative record creation.</violation>

<violation number="2" location="apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/V2OpenInMenuButton/V2OpenInMenuButton.tsx:60">
P2: The defensive `v2SidebarProjects.insert` uses `tabOrder: 0`, which can reorder the sidebar by forcing that project to the top.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Avoid a phantom v2SidebarProjects row with hard-coded tabOrder: 0 when
persisting the default Open In app. Delegate record creation to the
canonical sidebar state helper, which computes tabOrder correctly.
@saddlepaddle
saddlepaddle merged commit e9a272c into main Apr 13, 2026
6 of 7 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ⚠️ Neon database branch
  • ⚠️ Electric Fly.io app

Thank you for your contribution! 🎉

saddlepaddle added a commit that referenced this pull request Apr 13, 2026
…idebarProjects

Align with the per-project v2 "Open In" pattern established in #3393:
resolve the editor from collections.v2SidebarProjects.defaultOpenInApp
(falling back to finder) and call electronTrpc.external.openInApp —
instead of openFileInEditor, which does v1-era backend resolution off
the projects.defaultApp column that v2 doesn't write to.
saddlepaddle added a commit that referenced this pull request Apr 13, 2026
#3398)

* feat(desktop): modifier+click in file tree opens external editor / new tab

- Meta/Ctrl+click: open in external editor (works on files and folders)
- Shift+click: open file in a new internal tab
- Plain click: unchanged (preview pane / toggle folder)

Adds the behavior to both v1 (FileTreeItem, FileSearchResultItem) and v2
(WorkspaceFilesTreeItem) file trees. v2 guards the external-editor path
with an isLocalWorkspace check so remote hosts toast instead of silently
calling local electron IPC against a path that does not exist.

* fix(desktop): v2 file-tree meta+click reads defaultOpenInApp from v2SidebarProjects

Align with the per-project v2 "Open In" pattern established in #3393:
resolve the editor from collections.v2SidebarProjects.defaultOpenInApp
(falling back to finder) and call electronTrpc.external.openInApp —
instead of openFileInEditor, which does v1-era backend resolution off
the projects.defaultApp column that v2 doesn't write to.

* fix(desktop): address PR review on v2 file-tree meta+click

- Gate the isLocal check on the host live query actually resolving, so
  a meta+click before the collections query settles silently no-ops
  instead of toasting a misleading "only supported on local workspaces"
  error (cubic/greptile/coderabbit).
- Use a separator-safe split when deriving the new-tab title from a file
  path so Windows host paths (backslash-separated) don't produce a full
  path as the tab title (cubic).
MocA-Love pushed a commit to MocA-Love/superset that referenced this pull request Apr 13, 2026
…radevAI#3393)

* fix(desktop): v2 Open In editor is per-project, not per-workspace

The v2 top-bar "Open In <editor>" dropdown was storing the choice in
the per-workspace localStorage collection, so switching between
workspaces in the same project reset the picker. Move it to the
per-project v2SidebarProjects collection so the choice is shared
across every workspace in a project. Also drops the unused
workspaceId prop and no longer passes projectId to openInApp, since
persistence now happens entirely client-side on the react-db
collection.

* fix(desktop): use ensureProjectInSidebar helper in V2OpenInMenuButton

Avoid a phantom v2SidebarProjects row with hard-coded tabOrder: 0 when
persisting the default Open In app. Delegate record creation to the
canonical sidebar state helper, which computes tabOrder correctly.
MocA-Love pushed a commit to MocA-Love/superset that referenced this pull request Apr 13, 2026
ParadevAI#3398)

* feat(desktop): modifier+click in file tree opens external editor / new tab

- Meta/Ctrl+click: open in external editor (works on files and folders)
- Shift+click: open file in a new internal tab
- Plain click: unchanged (preview pane / toggle folder)

Adds the behavior to both v1 (FileTreeItem, FileSearchResultItem) and v2
(WorkspaceFilesTreeItem) file trees. v2 guards the external-editor path
with an isLocalWorkspace check so remote hosts toast instead of silently
calling local electron IPC against a path that does not exist.

* fix(desktop): v2 file-tree meta+click reads defaultOpenInApp from v2SidebarProjects

Align with the per-project v2 "Open In" pattern established in ParadevAI#3393:
resolve the editor from collections.v2SidebarProjects.defaultOpenInApp
(falling back to finder) and call electronTrpc.external.openInApp —
instead of openFileInEditor, which does v1-era backend resolution off
the projects.defaultApp column that v2 doesn't write to.

* fix(desktop): address PR review on v2 file-tree meta+click

- Gate the isLocal check on the host live query actually resolving, so
  a meta+click before the collections query settles silently no-ops
  instead of toasting a misleading "only supported on local workspaces"
  error (cubic/greptile/coderabbit).
- Use a separator-safe split when deriving the new-tab title from a file
  path so Windows host paths (backslash-separated) don't produce a full
  path as the tab title (cubic).
@Kitenite
Kitenite deleted the file-editor-time branch April 13, 2026 16:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant