From be62b0d0d5d03733dfdc8394604d71d8cfb1613a Mon Sep 17 00:00:00 2001 From: Andreas Asprou Date: Wed, 31 Dec 2025 09:36:39 +0200 Subject: [PATCH] feat(desktop): add configurable workspace navigation style (top-bar vs sidebar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a setting to let users choose between displaying workspaces as horizontal tabs in the TopBar (current behavior) or in a dedicated left sidebar (new feature, similar to Linear/GitHub Desktop). Key changes: - Add navigationStyle column to settings table (migration 0005) - Add navigation style dropdown in Behavior Settings - Create WorkspaceSidebar component with collapsible project sections - Create shared useWorkspaceShortcuts hook (⌘1-9 shortcuts, auto-create) - Update TopBar to conditionally render based on navigation style - Add βŒ˜β‡§B hotkey to toggle workspace sidebar πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .agents/commands/create-plan-file.md | 271 +++++ ...51231-1200-workspace-sidebar-navigation.md | 691 ++++++++++++ .../src/lib/trpc/routers/settings/index.ts | 21 + .../renderer/hooks/useWorkspaceShortcuts.ts | 117 +++ .../SettingsView/BehaviorSettings.tsx | 67 +- .../TopBar/WorkspaceSidebarControl.tsx | 40 + .../components/TopBar/WorkspaceTabs/index.tsx | 104 +- .../screens/main/components/TopBar/index.tsx | 36 +- .../ProjectSection/ProjectHeader.tsx | 42 + .../ProjectSection/ProjectSection.tsx | 76 ++ .../WorkspaceSidebar/ProjectSection/index.ts | 2 + .../ResizableWorkspaceSidebar.tsx | 94 ++ .../WorkspaceListItem/WorkspaceDiffStats.tsx | 16 + .../WorkspaceListItem/WorkspaceListItem.tsx | 104 ++ .../WorkspaceStatusBadge.tsx | 53 + .../WorkspaceListItem/index.ts | 3 + .../WorkspaceSidebar/WorkspaceSidebar.tsx | 45 + .../WorkspaceSidebarFooter.tsx | 62 ++ .../WorkspaceSidebarHeader.tsx | 10 + .../main/components/WorkspaceSidebar/index.ts | 2 + .../src/renderer/screens/main/index.tsx | 20 +- apps/desktop/src/renderer/stores/index.ts | 1 + .../stores/workspace-sidebar-state.ts | 97 ++ apps/desktop/src/shared/constants.ts | 1 + apps/desktop/src/shared/hotkeys.ts | 7 +- .../drizzle/0005_add_navigation_style.sql | 1 + .../local-db/drizzle/meta/0005_snapshot.json | 984 ++++++++++++++++++ packages/local-db/drizzle/meta/_journal.json | 7 + packages/local-db/src/schema/schema.ts | 6 + 29 files changed, 2863 insertions(+), 117 deletions(-) create mode 100644 .agents/commands/create-plan-file.md create mode 100644 apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md create mode 100644 apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts create mode 100644 apps/desktop/src/renderer/stores/workspace-sidebar-state.ts create mode 100644 packages/local-db/drizzle/0005_add_navigation_style.sql create mode 100644 packages/local-db/drizzle/meta/0005_snapshot.json diff --git a/.agents/commands/create-plan-file.md b/.agents/commands/create-plan-file.md new file mode 100644 index 00000000000..3b092d269ed --- /dev/null +++ b/.agents/commands/create-plan-file.md @@ -0,0 +1,271 @@ +# Superset Execution Plans (ExecPlans): + +> **DO NOT EDIT THIS FILE** +> This file is the ExecPlan template and guide only. +> Create plans in the appropriate location: +> - **App-specific work**: `apps//.agents/plans/-.md` +> - **Package work**: `packages//.agents/plans/-.md` +> - **Cross-app/shared work**: `.agents/plans/-.md` (root) + +This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context. + +## Process + +Steps: +1. Discovery & Orientation: map the repo, name the scope, enumerate unknowns. Capture initial Assumptions and Open Questions in the ExecPlan. +2. Question-driven Clarification: ask focused, acceptance-oriented questions grouped by plan section. Maintain the Open Questions list in the ExecPlan and pre-link each item to a Decision Log placeholder. +3. Draft the Plan: complete the ExecPlan skeleton end-to-end (Purpose, Context, Plan of Work, Validation, Idempotence, etc.), calling out risks and dependencies. +4. Resolve Questions: as answers arrive, immediately update the ExecPlanβ€”move items from Open Questions to the Decision Log with rationale; adjust Plan of Work and Acceptance accordingly. +5. Approval Gate: present the updated ExecPlan for approval. Do not implement until approved. +6. Implementation & Validation: implement per the plan, update Progress with timestamps, and validate via tests and acceptance. Log learnings in Surprises & Discoveries. +7. Closeout: write Outcomes & Retrospective; ensure the plan remains self-contained and accurate. +8. Write your plan to the appropriate location: + - App-specific: `apps//.agents/plans/-.md` + - Package-specific: `packages//.agents/plans/-.md` + - Cross-app: `.agents/plans/-.md` + Use `` in `YYYYMMDD-HHmm` format (e.g., `20240613-1045-my-feature-plan.md`). This ensures plans are sorted from most recent to oldest. +9. Plan Lifecycle: When the plan is complete and a PR is created, move it to the `done/` folder within the same directory. If abandoned, move it to `abandoned/`. + +Example questions: +``` +I reviewed the existing auth implementation in apps/web/src/app/auth/. + +Where should the new OAuth provider live? +a) apps/web/src/lib/auth/providers/ (co-located with auth logic) +b) packages/shared/src/auth/ (shared across apps) +c) Other (specify) + +How should we handle token refresh? +a) Silent refresh via interceptor +b) Explicit refresh on 401 response +c) Other (specify) +``` + +## How to use ExecPlans and PLANS.md + +When authoring an executable specification (ExecPlan), follow this document _to the letter_. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research. + +When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously, and commit frequently. + +When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work. + +When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation. + +## Requirements + +NON-NEGOTIABLE REQUIREMENTS: + +* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed. +* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained. +* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo. +* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition". +* Every ExecPlan must define every term of art in plain language or do not use it. + +Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe. + +The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan. + +## Formatting + +Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists. + +When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks. + +Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first. + +## Guidelines + +Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself. + +Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details. + +Question discipline and placement: +- Keep each question atomic and acceptance-oriented (what behavior must hold? how will we observe it?). +- Record questions in an Open Questions section of the ExecPlan; tag each with the plan section it affects (e.g., Validation, Plan of Work). +- When a question is answered, create a Decision Log entry with rationale and update the affected sections. Remove the item from Open Questions. +- Prefer at most 3-7 active questions; timebox low-impact ones or convert them into explicit Assumptions. + +Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to http://localhost:3000/health returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior). + +Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable. + +Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go. + +Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the project's toolchain and how to interpret their results. + +## Superset-Specific Context + +This is a Bun + Turborepo monorepo with the following structure: + +**Apps:** +- `apps/web` - Main web application (app.superset.sh) +- `apps/marketing` - Marketing site (superset.sh) +- `apps/admin` - Admin dashboard +- `apps/api` - API backend +- `apps/desktop` - Electron desktop application +- `apps/docs` - Documentation site +- `apps/cli` - CLI tooling + +**Packages:** +- `packages/ui` - Shared UI components (shadcn/ui + TailwindCSS v4) +- `packages/db` - Drizzle ORM database schema +- `packages/local-db` - Local database schema +- `packages/queries` - Shared query logic +- `packages/shared` - Shared constants and utilities +- `packages/trpc` - tRPC configuration + +**Common Commands:** +- `bun dev` - Start all dev servers +- `bun test` - Run tests +- `bun build` - Build all packages +- `bun run lint` - Check for lint issues +- `bun run lint:fix` - Fix auto-fixable lint issues +- `bun run typecheck` - Type check all packages +- `bun run db:push` - Apply schema changes +- `bun run db:migrate` - Run migrations + +Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs. + +## Milestones + +Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation. + +Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan. + +## Living plans and design decisions + +* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section. +* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional. +* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal). +* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you. +* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned. + +# Prototyping milestones and parallel implementations + +It is acceptable--and often encouraged--to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as "prototyping"; describe how to run and observe results; and state the criteria for promoting or discarding the prototype. + +Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation. + +## Skeleton of a Good ExecPlan + +```md +# + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +## Purpose / Big Picture + +Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable. + +## Assumptions + +State temporary assumptions that unblock planning. Every assumption must either be confirmed (moved to the Decision Log) or removed by implementation end. + +## Open Questions + +List unresolved, acceptance-oriented questions. For each, note the impacted plan sections (e.g., Validation, Plan of Work) and add a placeholder in the Decision Log for the eventual answer. + +## Progress + +Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two ("done" vs. "remaining"). This section must always reflect the actual current state of the work. + +- [x] (2025-10-01 13:00Z) Example completed step. +- [ ] Example incomplete step. +- [ ] Example partially completed step (completed: X; remaining: Y). + +Use timestamps to measure rates of progress. + +## Surprises & Discoveries + +Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence. + +- Observation: ... + Evidence: ... + +## Decision Log + +Record every decision made while working on the plan in the format: + +- Decision: ... + Rationale: ... + Date/Author: ... + +## Outcomes & Retrospective + +Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose. + +## Context and Orientation + +Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans. + +## Plan of Work + +Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal. + +## Concrete Steps + +State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds. + +## Validation and Acceptance + +Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run `bun test` and expect passed; the new test fails before the change and passes after". + +## Idempotence and Recovery + +If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion. + +## Artifacts and Notes + +Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success. + +## Interfaces and Dependencies + +Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `packages/db/src/schema/users.ts` or `apps/web/src/lib/auth.ts`. E.g.: + +In packages/db/src/schema/users.ts, define: + + export const users = pgTable('users', { + id: text('id').primaryKey(), + email: text('email').notNull().unique(), + createdAt: timestamp('created_at').defaultNow(), + }); +``` + +If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED. + +When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything. + +## Plan Lifecycle + +ExecPlans have a defined lifecycle that keeps the `.agents/plans/` folder clean and provides a historical record of completed work. + +### Directory Structure + +``` +apps//.agents/plans/ # App-specific plans + .md + done/ + abandoned/ + +packages//.agents/plans/ # Package-specific plans + .md + done/ + abandoned/ + +.agents/plans/ # Cross-app/shared plans + .md + done/ + abandoned/ +``` + +### When to Move Plans + +**To `done/`**: Move the plan to the `done/` folder within the same directory when creating a PR that completes the work. Before moving, ensure the plan's `Outcomes & Retrospective` section is filled in. + +**To `abandoned/`**: Move the plan to the `abandoned/` folder within the same directory if work is stopped without completion. Add a note explaining why (scope changed, approach invalidated, deprioritized, etc.). + +### Edge Cases + +- **PR closed without merging**: The plan stays in `done/`. If work resumes, move it back to the active plans folder and update the `Progress` section. +- **Plan spans multiple PRs**: Keep the plan in the active folder until the final PR. Reference intermediate PRs in the `Progress` section, then move to `done/` on the final PR. +- **Reopening abandoned work**: Move the plan from `abandoned/` back to the active plans folder and update the `Progress` section to reflect the restart. diff --git a/apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md b/apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md new file mode 100644 index 00000000000..88aba9783e4 --- /dev/null +++ b/apps/desktop/.agents/plans/20251231-1200-workspace-sidebar-navigation.md @@ -0,0 +1,691 @@ +# Configurable Workspace Navigation: Sidebar Mode + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. + +## Purpose / Big Picture + +Currently, workspaces are displayed as horizontal tabs in the TopBar, grouped by project. This change allows users to configure an alternative "sidebar" navigation style where workspaces appear in a dedicated left sidebar panel, matching designs from tools like Linear/GitHub Desktop. + +After this change, users can: +1. Open Settings > Behavior and toggle "Navigation style" between "Top bar" and "Sidebar" +2. In sidebar mode, see a dedicated workspace sidebar with collapsible project sections +3. Switch between workspaces by clicking items in the sidebar +4. See PR status, diff stats, and keyboard shortcuts inline with workspace items +5. Continue using ⌘1-9 shortcuts to switch workspaces regardless of mode + +## Design Reference + +The target design (based on provided mockup): + + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ [Sidebar Toggle] [Workbench|Review] [Branch β–Ύ] [Open In β–Ύ] [Avatar]β”‚ <- TopBar (sidebar mode) + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ ≑ Workspaces β”‚ β”‚ + β”‚ β”‚ β”‚ + β”‚ web β”‚ β”‚ + β”‚ + New workspace ... β”‚ Main Content Area β”‚ + β”‚ ┃ andreasasprou/cebu β”‚ (Workbench or Review mode) β”‚ + β”‚ cebu Β· PR #144 β”‚ β”‚ + β”‚ Ready to merge ⌘1 β”‚ β”‚ + β”‚ +1850 -301 β”‚ β”‚ + β”‚ β”‚ β”‚ + β”‚ β–Έ andreasasprou/feat β”‚ β”‚ + β”‚ harare Β· PR #107 β”‚ β”‚ + β”‚ Merge conflicts ⌘2 β”‚ β”‚ + β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ nova β”‚ β”‚ + β”‚ + New workspace ... β”‚ Changes Sidebar β”‚ + β”‚ ┃ andreasasprou/pdf β”‚ (existing ResizableSidebar) β”‚ + β”‚ la-paz-v2 Β· PR#720 β”‚ β”‚ + β”‚ Uncommitted ⌘3 β”‚ β”‚ + β”‚ +23823 -5 β”‚ β”‚ + β”‚ β”‚ β”‚ + β”‚ frontend β”‚ β”‚ + β”‚ + New workspace ... β”‚ β”‚ + β”‚ β”‚ β”‚ + │──────────────────── β”‚ β”‚ + β”‚ [+] Add project β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + Workspace Changes Content + Sidebar Sidebar (Mosaic Panes) + (NEW) (existing) + +Key visual elements: +- Active workspace: Green/project-colored left border (┃) +- Status badges: "Ready to merge", "Merge conflicts", "Uncommitted changes", "Archive" +- Diff stats: +insertions -deletions (always visible for active, hover for others) +- Keyboard shortcuts: ⌘1-9 displayed inline +- Collapsible project sections with header + "..." context menu +- "+ New workspace" per project section +- "Add project" at bottom footer + +## Assumptions + +1. The existing `WorkspaceHoverCard` already fetches PR status via `workspaces.getGitHubStatus` and can be reused +2. The `feat/desktop-workbench-review-mode` branch changes are the baseline (already rebased) +3. Users will primarily use one mode or the other, not switch frequently +4. The `packages/local-db` migration system handles schema changes on app startup + +## Open Questions + +(All questions resolved - see Decision Log) + +## Progress + +- [ ] Initial plan created and awaiting approval +- [ ] (Pending) Milestone 1: Add navigation style setting +- [ ] (Pending) Milestone 2: Create WorkspaceSidebar component +- [ ] (Pending) Milestone 3: Create sidebar-mode TopBar variant +- [ ] (Pending) Milestone 4: Wire up setting to conditionally render layouts +- [ ] (Pending) Milestone 5: Polish and validation + +## Surprises & Discoveries + +(To be filled during implementation) + +## Decision Log + +- **Decision**: Navigation style setting stored in SQLite settings table via existing tRPC pattern + - Rationale: Matches existing "confirmOnQuit" behavior setting pattern, persists across sessions + - Date: 2025-12-31 / Planning phase + +- **Decision**: Workspace sidebar is a NEW dedicated sidebar, not a mode in existing ModeCarousel + - Rationale: User preference for dedicated panel, keeps workspaces separate from terminal tabs/changes + - Date: 2025-12-31 / Planning phase + +- **Decision**: Both sidebars independently resizable + - Rationale: User may want different widths for workspace nav vs file changes + - Date: 2025-12-31 / Planning phase + +- **Decision**: ⌘1-9 shortcuts work in both navigation modes + - Rationale: Consistency for keyboard users regardless of UI layout preference + - Date: 2025-12-31 / Planning phase + +- **Decision**: Manual testing only, no automated tests for initial release + - Rationale: Feature is primarily UI/layout, visual verification more appropriate + - Date: 2025-12-31 / Planning phase + +- **Decision**: Workspace sidebar width persisted independently from changes sidebar + - Rationale: Users may want different widths for workspace nav vs file changes + - Date: 2025-12-31 / Planning phase + +- **Decision**: Workspace display format is "github-username/branch-name" (e.g., "andreasasprou/cebu") + - Rationale: Matches GitHub PR branch naming, provides author context + - Date: 2025-12-31 / Planning phase + +- **Decision**: Skip "Archive" status badge for initial release + - Rationale: Archive feature doesn't exist in app, can add later if needed + - Date: 2025-12-31 / Planning phase + +- **Decision**: Keep Workbench/Review toggle and Open In in WorkspaceActionBar, not TopBar + - Rationale: Avoids duplicating components, maintains consistent location across navigation modes + - Date: 2025-12-31 / Planning phase (review feedback) + +- **Decision**: Sidebar toggles use distinct naming: "Workspaces" and "Files" with different icons + - Rationale: With two sidebars, "Toggle sidebar" is ambiguous. Clear naming prevents confusion + - Date: 2025-12-31 / Planning phase (review feedback) + +- **Decision**: Workspace sidebar is toggleable (not always-on), default open on first use + - Rationale: Matches changes sidebar pattern, provides flexibility for screen sizes + - Date: 2025-12-31 / Planning phase (review feedback) + +- **Decision**: Use `workspaces.getGitHubStatus` for diff stats, lazy-load on hover + - Rationale: Reuses existing infrastructure, avoids N+1 queries, matches WorkspaceHoverCard + - Date: 2025-12-31 / Planning phase (review feedback) + +- **Decision**: Extract ⌘1-9 shortcuts and auto-create workspace logic into shared hook + - Rationale: These behaviors must work in BOTH navigation modes, avoiding code duplication + - Date: 2025-12-31 / Planning phase (review feedback) + +## Outcomes & Retrospective + +(To be filled at completion) + +--- + +## Context and Orientation + +### Current Architecture + +The desktop app (`apps/desktop/`) uses: + +**Layout Structure** (in `src/renderer/screens/main/`): +- `MainScreen` - Root component, manages view state (workspace/settings/tasks) +- `TopBar` - Contains `WorkspacesTabs` for horizontal workspace navigation +- `WorkspaceView` - Main content area with `ResizableSidebar` (changes) + `ContentView` + +**State Management**: +- `sidebar-state.ts` - Zustand store for changes sidebar (width, visibility, mode) +- `workspace-view-mode.ts` - Zustand store for Workbench/Review mode per workspace +- `app-state.ts` - Current view, settings section, etc. + +**Settings System**: +- Settings stored in SQLite via `packages/local-db/src/schema/schema.ts` +- tRPC routes in `src/lib/trpc/routers/settings/` +- UI in `src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx` + +**Key Files**: +- `src/renderer/screens/main/components/TopBar/index.tsx` - Current TopBar +- `src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx` - Horizontal tabs +- `src/renderer/screens/main/components/WorkspaceView/index.tsx` - Main workspace layout +- `src/renderer/screens/main/components/WorkspaceView/ResizableSidebar/` - Existing sidebar + +### Terminology + +- **Navigation style**: User preference for workspace display location ("top-bar" or "sidebar") +- **Workspace sidebar**: NEW left panel showing workspaces grouped by project +- **Changes sidebar**: EXISTING left panel showing git changes (file tree) +- **Workbench mode**: Terminal panes + file viewers (mosaic layout) +- **Review mode**: Full-page changes/diff view + +--- + +## Plan of Work + +### Milestone 1: Add Navigation Style Setting + +Add the setting infrastructure following the existing "confirmOnQuit" pattern. + +**1.1 Add setting to database schema** + +In `packages/local-db/src/schema/schema.ts`, add to settings table: + + navigationStyle: text("navigation_style").$type<"top-bar" | "sidebar">(), + +**1.2 Generate local-db migration** + +Run from `packages/local-db`: + + pnpm drizzle-kit generate --name="add_navigation_style" + +This creates a migration file in `packages/local-db/drizzle/`. The migration runs automatically on app startup via `apps/desktop/src/main/lib/local-db/index.ts` migrate logic. + +**IMPORTANT**: Do NOT use `bun run db:push` - that targets packages/db (Neon/Postgres), not local-db. + +**1.3 Add default constant** + +In `apps/desktop/src/shared/constants.ts`: + + export const DEFAULT_NAVIGATION_STYLE = "top-bar" as const; + export type NavigationStyle = "top-bar" | "sidebar"; + +**1.4 Add tRPC routes** + +In `apps/desktop/src/lib/trpc/routers/settings/index.ts`, add: + + getNavigationStyle: publicProcedure.query(async () => { + const row = getSettings(); + return row.navigationStyle ?? DEFAULT_NAVIGATION_STYLE; + }), + + setNavigationStyle: publicProcedure + .input(z.object({ style: z.enum(["top-bar", "sidebar"]) })) + .mutation(async ({ input }) => { + localDb.insert(settings) + .values({ id: 1, navigationStyle: input.style }) + .onConflictDoUpdate({ + target: settings.id, + set: { navigationStyle: input.style } + }) + .run(); + return { success: true }; + }), + +**1.5 Add UI in BehaviorSettings** + +In `apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx`, add a toggle/select for "Navigation style" with options "Top bar" and "Sidebar". + +### Milestone 2: Create WorkspaceSidebar Component + +Create the new sidebar component matching the design. + +**2.1 Create store for workspace sidebar state** + +Create `apps/desktop/src/renderer/stores/workspace-sidebar-state.ts`: + + interface WorkspaceSidebarState { + isOpen: boolean; + width: number; + // Use string[] instead of Set for JSON serialization with Zustand persist + collapsedProjectIds: string[]; + toggleOpen: () => void; + setWidth: (width: number) => void; + toggleProjectCollapsed: (projectId: string) => void; + isProjectCollapsed: (projectId: string) => boolean; + } + +**NOTE**: Do NOT use `Set` for `collapsedProjectIds` - Zustand persist uses JSON serialization which drops Sets. Use `string[]` and provide helper methods. + +**2.2 Create component structure** + +Create folder: `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/` + +Files to create: +- `index.tsx` - Main component +- `WorkspaceSidebarHeader.tsx` - "Workspaces" header with icon +- `ProjectSection/ProjectSection.tsx` - Collapsible project group +- `ProjectSection/ProjectHeader.tsx` - Project name + actions +- `WorkspaceListItem/WorkspaceListItem.tsx` - Individual workspace row +- `WorkspaceListItem/WorkspaceStatusBadge.tsx` - Status badges +- `WorkspaceListItem/WorkspaceDiffStats.tsx` - +/- diff display +- `WorkspaceSidebarFooter.tsx` - "Add project" button + +**2.3 WorkspaceListItem design** + +Each workspace item displays: +- Left border (project color when active) +- Branch icon (git-branch, git-pull-request, etc. based on type) +- Author/branch: "andreasasprou/feature-name" +- Worktree name + PR info: "worktree-city Β· PR #123" +- Status badge: "Ready to merge" / "Merge conflicts" / "Uncommitted changes" / "Archive" +- Keyboard shortcut badge: "⌘1" +- Diff stats (for active): "+1850 -301" + +**2.4 Data fetching** + +Reuse existing queries: +- `trpc.workspaces.getAllGrouped.useQuery()` for project/workspace list +- `trpc.workspaces.getActive.useQuery()` for active workspace + +**Diff stats source**: Use `workspaces.getGitHubStatus` (already used by WorkspaceHoverCard) for PR additions/deletions. This is the authoritative source. Do NOT add a new git diff endpoint. For workspaces without PRs, show local uncommitted changes count from the changes router as fallback. + +**Performance consideration**: Avoid N+1 `getGitHubStatus` calls per workspace row. Options: +1. Extend `getAllGrouped` to include a summary `githubStatus` field (batched) +2. Reuse cached data from `worktrees.githubStatus` if already fetched +3. Lazy-load status on hover only (simplest, matches current WorkspaceHoverCard behavior) + +Recommended: Start with option 3 (lazy-load on hover) to match existing patterns, then optimize with batching if performance is an issue. + +**2.5 Extract shared workspace behaviors** + +Currently `WorkspacesTabs/index.tsx` owns critical behaviors that must work in BOTH navigation modes: +- ⌘1-9 workspace switching shortcuts +- Auto-create main workspace for new projects effect + +Create a shared hook: `apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts` + + export function useWorkspaceShortcuts() { + const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); + const setActiveWorkspace = useSetActiveWorkspace(); + const createBranchWorkspace = useCreateBranchWorkspace(); + + // Flatten workspaces for ⌘1-9 navigation + const allWorkspaces = groups.flatMap((group) => group.workspaces); + + // ⌘1-9 shortcuts + useHotkeys(workspaceKeys, handleWorkspaceSwitch); + useHotkeys(HOTKEYS.PREV_WORKSPACE.keys, handlePrevWorkspace); + useHotkeys(HOTKEYS.NEXT_WORKSPACE.keys, handleNextWorkspace); + + // Auto-create main workspace for new projects + useEffect(() => { /* existing logic */ }, [groups]); + + return { allWorkspaces }; + } + +Then use this hook in BOTH: +- `WorkspaceSidebar/index.tsx` (sidebar mode) +- `WorkspacesTabs/index.tsx` (top-bar mode) + +This ensures shortcuts work regardless of navigation style. + +### Milestone 3: Create Sidebar-Mode TopBar Variant + +When navigation style is "sidebar", the TopBar should show a unified bar without workspace tabs. + +**3.1 Decide on control placement** + +Currently, `WorkspaceActionBar` contains: +- ViewModeToggle (Workbench/Review) +- Branch selector +- Open In dropdown + +**Decision needed**: In sidebar mode, do these controls: +A) Stay in WorkspaceActionBar (below TopBar) - no duplication, consistent location +B) Move to TopBarSidebarMode - more prominent, frees up vertical space + +**Recommendation**: Keep controls in WorkspaceActionBar (option A). This: +- Avoids duplicating components +- Maintains consistent location across modes +- Keeps TopBar focused on navigation + +TopBarSidebarMode then only needs: +- Changes sidebar toggle (existing SidebarControl, renamed for clarity) +- Workspace sidebar toggle (new) +- Avatar/user menu + +**3.2 Sidebar toggle disambiguation** + +With two sidebars, we need clear naming: +- **"Files" / file icon**: Toggle changes sidebar (existing, currently just "sidebar") +- **"Workspaces" / layers icon**: Toggle workspace sidebar (new) + +Update tooltips and potentially add labels on hover. Both toggles live in TopBar. + +**3.3 Create TopBarSidebarMode component** + +Create `apps/desktop/src/renderer/screens/main/components/TopBar/TopBarSidebarMode.tsx`: + +Layout (left to right): +- Workspace sidebar toggle (new, tooltip: "Toggle workspaces") +- Changes sidebar toggle (existing SidebarControl, tooltip: "Toggle files") +- [Spacer] +- [Right] Avatar dropdown + +The Workbench/Review toggle, branch selector, and Open In dropdown remain in WorkspaceActionBar. + +**3.4 Workspace sidebar always-on vs toggleable** + +The workspace sidebar should be toggleable (not always-on) because: +- Users may want full-width content when not switching workspaces +- Matches the existing changes sidebar pattern +- Provides flexibility for different screen sizes + +Default state: Open (on first use), then persisted via Zustand. + +**3.5 Conditional rendering in TopBar** + +Modify `apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx`: + + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + + if (navigationStyle === "sidebar") { + return ; + } + + return ; // Rename current implementation + +### Milestone 4: Wire Up Layout Switching + +Connect the setting to conditionally render the appropriate layout. + +**4.1 Modify MainScreen layout** + +In `apps/desktop/src/renderer/screens/main/index.tsx`, when rendering workspace view: + + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + + // In render: + {navigationStyle === "sidebar" && } + + +**4.2 Modify WorkspaceView** + +The `WorkspaceView` component remains largely unchanged - it already has the ResizableSidebar (changes) and ContentView. The WorkspaceSidebar sits to its left. + +**4.3 Layout structure in sidebar mode** + +
+ {/* NEW - workspace navigation */} + {/* EXISTING - contains changes sidebar + content */} +
+ +### Milestone 5: Polish and Validation + +**5.1 Keyboard shortcuts** + +Ensure ⌘1-9 workspace switching works in both modes. The existing `useHotkeys` in `WorkspacesTabs/index.tsx` should be moved/shared. + +**5.2 Hover preview** + +Implement hover preview showing branch + PR status. Can reuse `WorkspaceHoverCard` component or adapt it. + +**5.3 Animations** + +- Smooth sidebar show/hide with Framer Motion +- Collapse/expand project sections with animation +- Active workspace indicator transition + +**5.4 Persistence** + +- Workspace sidebar width persists (Zustand + localStorage) +- Collapsed project sections persist +- Navigation style persists (SQLite) + +--- + +## Concrete Steps + +All commands run from repository root: `/Users/andreasasprou/.superset/worktrees/superset/workspace-sidebar` + +**Step 1: Verify current state** + + cd apps/desktop + bun run typecheck + +Expected: No type errors (baseline) + +**Step 2: Add database schema field and generate migration** + +Edit `packages/local-db/src/schema/schema.ts` to add `navigationStyle` column. + + cd packages/local-db + pnpm drizzle-kit generate --name="add_navigation_style" + +Expected: Migration file created in `packages/local-db/drizzle/` + +The migration runs automatically on app startup. Do NOT use `bun run db:push` (that's for Neon/Postgres). + +**Step 3: Add setting routes** + +Edit `apps/desktop/src/lib/trpc/routers/settings/index.ts` + + bun run typecheck + +Expected: Types pass with new routes + +**Step 4: Create WorkspaceSidebar component** + +Create component files as specified in Milestone 2. + +**Step 5: Add to layout** + +Wire up conditional rendering in MainScreen. + + bun dev + +Expected: App starts, can toggle setting, layout switches + +**Step 6: Full validation** + + bun run lint:fix + bun run typecheck + bun test + +Expected: All pass + +--- + +## Validation and Acceptance + +### Manual Testing Checklist + +1. **Setting toggle works** + - Open Settings > Behavior + - See "Navigation style" option + - Toggle between "Top bar" and "Sidebar" + - Layout changes immediately (or after brief transition) + +2. **Sidebar mode displays correctly** + - WorkspaceSidebar appears on left + - Projects shown as collapsible sections + - Workspaces listed under each project + - Active workspace has colored left border + - Status badges visible + - Diff stats visible for active workspace + - ⌘1-9 shortcuts displayed + +3. **Interactions work** + - Click workspace to switch + - Click project header to collapse/expand + - Hover shows preview card + - ⌘1-9 switches workspaces + - "+ New workspace" opens creation dialog + - "Add project" opens project creation + +4. **TopBar adapts** + - In sidebar mode: No workspace tabs, unified bar with Workbench/Review toggle + - In top-bar mode: Original layout preserved + +5. **Persistence** + - Close and reopen app + - Navigation style preserved + - Sidebar widths preserved + - Collapsed projects preserved + +6. **Both sidebars coexist** + - Workspace sidebar (left) + - Changes sidebar (right of workspace sidebar) + - Both independently resizable + - Both can be toggled independently + +--- + +## Idempotence and Recovery + +- Database schema changes are additive (new nullable column) +- Running `db:push` multiple times is safe +- Component files are new additions, no destructive changes +- Setting defaults to "top-bar" if not set (backwards compatible) +- If implementation fails partway, the existing top-bar mode continues working + +--- + +## Artifacts and Notes + +### Component File Structure + + apps/desktop/src/renderer/ + β”œβ”€β”€ hooks/ + β”‚ └── useWorkspaceShortcuts.ts (new - shared ⌘1-9 + auto-create logic) + β”œβ”€β”€ screens/main/components/ + β”‚ β”œβ”€β”€ WorkspaceSidebar/ + β”‚ β”‚ β”œβ”€β”€ index.tsx + β”‚ β”‚ β”œβ”€β”€ WorkspaceSidebarHeader.tsx + β”‚ β”‚ β”œβ”€β”€ WorkspaceSidebarFooter.tsx + β”‚ β”‚ β”œβ”€β”€ ResizableWorkspaceSidebar.tsx (wrapper with resize handle) + β”‚ β”‚ β”œβ”€β”€ ProjectSection/ + β”‚ β”‚ β”‚ β”œβ”€β”€ ProjectSection.tsx + β”‚ β”‚ β”‚ β”œβ”€β”€ ProjectHeader.tsx + β”‚ β”‚ β”‚ └── index.ts + β”‚ β”‚ └── WorkspaceListItem/ + β”‚ β”‚ β”œβ”€β”€ WorkspaceListItem.tsx + β”‚ β”‚ β”œβ”€β”€ WorkspaceStatusBadge.tsx + β”‚ β”‚ β”œβ”€β”€ WorkspaceDiffStats.tsx + β”‚ β”‚ └── index.ts + β”‚ └── TopBar/ + β”‚ β”œβ”€β”€ index.tsx (modified - conditional render) + β”‚ β”œβ”€β”€ TopBarSidebarMode.tsx (new) + β”‚ β”œβ”€β”€ TopBarDefault.tsx (renamed from inline JSX) + β”‚ β”œβ”€β”€ SidebarControl.tsx (updated tooltip: "Toggle files") + β”‚ β”œβ”€β”€ WorkspaceSidebarControl.tsx (new - "Toggle workspaces") + β”‚ └── ... (existing files) + └── stores/ + └── workspace-sidebar-state.ts (new) + +### State Structure + + // workspace-sidebar-state.ts (Zustand + persist) + { + isOpen: true, + width: 280, // pixels + collapsedProjectIds: ["project-id-1", "project-id-2"], // string[] NOT Set + } + + // settings table (SQLite via local-db) + { + navigationStyle: "sidebar" | "top-bar" + } + +**Important**: Use `string[]` for `collapsedProjectIds`, not `Set`. Zustand persist uses JSON serialization which drops Sets. + +--- + +## Interfaces and Dependencies + +### New tRPC Routes + +In `apps/desktop/src/lib/trpc/routers/settings/index.ts`: + + getNavigationStyle: publicProcedure.query(() => NavigationStyle) + setNavigationStyle: publicProcedure.input({ style: NavigationStyle }).mutation() + +### New Zustand Store + +In `apps/desktop/src/renderer/stores/workspace-sidebar-state.ts`: + + export const useWorkspaceSidebarStore = create()( + devtools( + persist( + (set, get) => ({ + isOpen: true, + width: 280, + collapsedProjectIds: [], // string[] for JSON serialization + + toggleOpen: () => set((s) => ({ isOpen: !s.isOpen })), + + setWidth: (width) => set({ width }), + + toggleProjectCollapsed: (projectId) => + set((s) => ({ + collapsedProjectIds: s.collapsedProjectIds.includes(projectId) + ? s.collapsedProjectIds.filter((id) => id !== projectId) + : [...s.collapsedProjectIds, projectId], + })), + + isProjectCollapsed: (projectId) => + get().collapsedProjectIds.includes(projectId), + }), + { name: "workspace-sidebar-store" } + ) + ) + ); + +### New Shared Hook + +In `apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts`: + + export function useWorkspaceShortcuts() { + // Extract from WorkspacesTabs: ⌘1-9 shortcuts + auto-create logic + // Used by BOTH WorkspaceSidebar and WorkspacesTabs + } + +### Component Props + + interface WorkspaceListItemProps { + workspace: { + id: string; + name: string; + branch: string; + worktreePath: string; + type: "worktree" | "branch"; + projectId: string; + }; + project: { + id: string; + name: string; + color: string; + }; + isActive: boolean; + index: number; // for ⌘N shortcut display + onSelect: () => void; + onHover: () => void; + } + +--- + +## Dependencies on External Data + +The following data is needed for full feature parity with the design: + +1. **PR Status + Diff Stats** - Available via `workspaces.getGitHubStatus` (already used by WorkspaceHoverCard) + - This is the authoritative source for PR additions/deletions + - Do NOT add a new git diff endpoint + +2. **Workspace Status** (uncommitted changes) - Available via changes router + - Fallback for workspaces without PRs + +3. **GitHub Author/Branch** - Extract from PR branch name or remote tracking branch + - Already available in workspace data + +**Performance strategy**: Lazy-load status on hover (matching WorkspaceHoverCard behavior). If batching is needed later, extend `getAllGrouped` to include summary status. diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 8c2e5ed6e83..abc5cd8f903 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -6,6 +6,7 @@ import { import { localDb } from "main/lib/local-db"; import { DEFAULT_CONFIRM_ON_QUIT, + DEFAULT_NAVIGATION_STYLE, DEFAULT_TERMINAL_LINK_BEHAVIOR, } from "shared/constants"; import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; @@ -207,5 +208,25 @@ export const createSettingsRouter = () => { return { success: true }; }), + + getNavigationStyle: publicProcedure.query(() => { + const row = getSettings(); + return row.navigationStyle ?? DEFAULT_NAVIGATION_STYLE; + }), + + setNavigationStyle: publicProcedure + .input(z.object({ style: z.enum(["top-bar", "sidebar"]) })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, navigationStyle: input.style }) + .onConflictDoUpdate({ + target: settings.id, + set: { navigationStyle: input.style }, + }) + .run(); + + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts new file mode 100644 index 00000000000..cd222045883 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { trpc } from "renderer/lib/trpc"; +import { + useCreateBranchWorkspace, + useSetActiveWorkspace, +} from "renderer/react-query/workspaces"; +import { HOTKEYS } from "shared/hotkeys"; + +/** + * Shared hook for workspace keyboard shortcuts and auto-creation logic. + * This hook should be used in both: + * - WorkspacesTabs (top-bar mode) + * - WorkspaceSidebar (sidebar mode) + * + * It handles: + * - ⌘1-9 workspace switching shortcuts + * - Previous/next workspace shortcuts + * - Auto-create main workspace for new projects + */ +export function useWorkspaceShortcuts() { + const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const activeWorkspaceId = activeWorkspace?.id || null; + const setActiveWorkspace = useSetActiveWorkspace(); + const createBranchWorkspace = useCreateBranchWorkspace(); + + // Track projects we've attempted to create workspaces for (persists across renders) + const attemptedProjectsRef = useRef>(new Set()); + const [isCreating, setIsCreating] = useState(false); + + // Auto-create main workspace for new projects (one-time per project) + useEffect(() => { + if (isCreating) return; + + for (const group of groups) { + const projectId = group.project.id; + const hasMainWorkspace = group.workspaces.some( + (w) => w.type === "branch", + ); + + // Skip if already has main workspace or we've already attempted this project + if (hasMainWorkspace || attemptedProjectsRef.current.has(projectId)) { + continue; + } + + // Mark as attempted before creating (prevents retries) + attemptedProjectsRef.current.add(projectId); + setIsCreating(true); + + // Auto-create fails silently - this is a background convenience feature + createBranchWorkspace.mutate( + { projectId }, + { + onSettled: () => { + setIsCreating(false); + }, + }, + ); + // Only create one at a time + break; + } + }, [groups, isCreating, createBranchWorkspace]); + + // Flatten workspaces for keyboard navigation + const allWorkspaces = groups.flatMap((group) => group.workspaces); + + // Workspace switching shortcuts (⌘+1-9) + const workspaceKeys = Array.from( + { length: 9 }, + (_, i) => `meta+${i + 1}`, + ).join(", "); + + const handleWorkspaceSwitch = useCallback( + (event: KeyboardEvent) => { + const num = Number(event.key); + if (num >= 1 && num <= 9) { + const workspace = allWorkspaces[num - 1]; + if (workspace) { + setActiveWorkspace.mutate({ id: workspace.id }); + } + } + }, + [allWorkspaces, setActiveWorkspace], + ); + + const handlePrevWorkspace = useCallback(() => { + if (!activeWorkspaceId) return; + const currentIndex = allWorkspaces.findIndex( + (w) => w.id === activeWorkspaceId, + ); + if (currentIndex > 0) { + setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex - 1].id }); + } + }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); + + const handleNextWorkspace = useCallback(() => { + if (!activeWorkspaceId) return; + const currentIndex = allWorkspaces.findIndex( + (w) => w.id === activeWorkspaceId, + ); + if (currentIndex < allWorkspaces.length - 1) { + setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex + 1].id }); + } + }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); + + useHotkeys(workspaceKeys, handleWorkspaceSwitch); + useHotkeys(HOTKEYS.PREV_WORKSPACE.keys, handlePrevWorkspace); + useHotkeys(HOTKEYS.NEXT_WORKSPACE.keys, handleNextWorkspace); + + return { + groups, + allWorkspaces, + activeWorkspaceId, + setActiveWorkspace, + }; +} diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx index bdc930ec4f0..bbcd9c9d80f 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx @@ -10,33 +10,52 @@ import { import { Switch } from "@superset/ui/switch"; import { trpc } from "renderer/lib/trpc"; +type NavigationStyle = "top-bar" | "sidebar"; + export function BehaviorSettings() { const utils = trpc.useUtils(); - const { data: confirmOnQuit, isLoading } = + + // Confirm on quit setting + const { data: confirmOnQuit, isLoading: isConfirmLoading } = trpc.settings.getConfirmOnQuit.useQuery(); const setConfirmOnQuit = trpc.settings.setConfirmOnQuit.useMutation({ onMutate: async ({ enabled }) => { - // Cancel outgoing fetches await utils.settings.getConfirmOnQuit.cancel(); - // Snapshot previous value const previous = utils.settings.getConfirmOnQuit.getData(); - // Optimistically update utils.settings.getConfirmOnQuit.setData(undefined, enabled); return { previous }; }, onError: (_err, _vars, context) => { - // Rollback on error if (context?.previous !== undefined) { utils.settings.getConfirmOnQuit.setData(undefined, context.previous); } }, onSettled: () => { - // Refetch to ensure sync with server utils.settings.getConfirmOnQuit.invalidate(); }, }); - const handleToggle = (enabled: boolean) => { + // Navigation style setting + const { data: navigationStyle, isLoading: isNavLoading } = + trpc.settings.getNavigationStyle.useQuery(); + const setNavigationStyle = trpc.settings.setNavigationStyle.useMutation({ + onMutate: async ({ style }) => { + await utils.settings.getNavigationStyle.cancel(); + const previous = utils.settings.getNavigationStyle.getData(); + utils.settings.getNavigationStyle.setData(undefined, style); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getNavigationStyle.setData(undefined, context.previous); + } + }, + onSettled: () => { + utils.settings.getNavigationStyle.invalidate(); + }, + }); + + const handleConfirmToggle = (enabled: boolean) => { setConfirmOnQuit.mutate({ enabled }); }; @@ -71,6 +90,10 @@ export function BehaviorSettings() { }); }; + const handleNavigationStyleChange = (style: NavigationStyle) => { + setNavigationStyle.mutate({ style }); + }; + return (
@@ -81,6 +104,32 @@ export function BehaviorSettings() {
+ {/* Navigation Style */} +
+
+ +

+ Choose how workspaces are displayed +

+
+ +
+ + {/* Confirm on Quit */}
diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx new file mode 100644 index 00000000000..9f73f0e465b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx @@ -0,0 +1,40 @@ +import { Button } from "@superset/ui/button"; +import { Kbd, KbdGroup } from "@superset/ui/kbd"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { LuPanelLeft, LuPanelLeftClose } from "react-icons/lu"; +import { useWorkspaceSidebarStore } from "renderer/stores"; +import { HOTKEYS } from "shared/hotkeys"; + +export function WorkspaceSidebarControl() { + const { isOpen, toggleOpen } = useWorkspaceSidebarStore(); + + return ( + + + + + + + Toggle Workspaces + + {HOTKEYS.TOGGLE_WORKSPACE_SIDEBAR.display.map((key) => ( + {key} + ))} + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx index fedaaba8267..8c8455c85ad 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx @@ -1,15 +1,9 @@ -import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; -import { trpc } from "renderer/lib/trpc"; -import { - useCreateBranchWorkspace, - useSetActiveWorkspace, -} from "renderer/react-query/workspaces"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; import { useCurrentView, useIsSettingsTabOpen, } from "renderer/stores/app-state"; -import { HOTKEYS } from "shared/hotkeys"; import { CreateWorkspaceButton } from "./CreateWorkspaceButton"; import { SettingsTab } from "./SettingsTab"; import { WorkspaceGroup } from "./WorkspaceGroup"; @@ -19,11 +13,9 @@ const MAX_WORKSPACE_WIDTH = 160; const ADD_BUTTON_WIDTH = 40; export function WorkspacesTabs() { - const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const activeWorkspaceId = activeWorkspace?.id || null; - const setActiveWorkspace = useSetActiveWorkspace(); - const createBranchWorkspace = useCreateBranchWorkspace(); + // Use shared hook for workspace shortcuts and auto-create logic + const { groups, allWorkspaces, activeWorkspaceId } = useWorkspaceShortcuts(); + const currentView = useCurrentView(); const isSettingsTabOpen = useIsSettingsTabOpen(); const isSettingsActive = currentView === "settings"; @@ -36,92 +28,6 @@ export function WorkspacesTabs() { null, ); - // Track projects we've attempted to create workspaces for (persists across renders) - // Using ref to avoid re-triggering the effect - const attemptedProjectsRef = useRef>(new Set()); - const [isCreating, setIsCreating] = useState(false); - - // Auto-create main workspace for new projects (one-time per project) - // This only runs for projects we haven't attempted yet - useEffect(() => { - if (isCreating) return; - - for (const group of groups) { - const projectId = group.project.id; - const hasMainWorkspace = group.workspaces.some( - (w) => w.type === "branch", - ); - - // Skip if already has main workspace or we've already attempted this project - if (hasMainWorkspace || attemptedProjectsRef.current.has(projectId)) { - continue; - } - - // Mark as attempted before creating (prevents retries) - attemptedProjectsRef.current.add(projectId); - setIsCreating(true); - - // Auto-create fails silently - this is a background convenience feature - // Users can manually create the workspace via the dropdown if needed - createBranchWorkspace.mutate( - { projectId }, - { - onSettled: () => { - setIsCreating(false); - }, - }, - ); - // Only create one at a time - break; - } - }, [groups, isCreating, createBranchWorkspace]); - - // Flatten workspaces for keyboard navigation - const allWorkspaces = groups.flatMap((group) => group.workspaces); - - // Workspace switching shortcuts (⌘+1-9) - combined into single hook call - const workspaceKeys = Array.from( - { length: 9 }, - (_, i) => `meta+${i + 1}`, - ).join(", "); - - const handleWorkspaceSwitch = useCallback( - (event: KeyboardEvent) => { - const num = Number(event.key); - if (num >= 1 && num <= 9) { - const workspace = allWorkspaces[num - 1]; - if (workspace) { - setActiveWorkspace.mutate({ id: workspace.id }); - } - } - }, - [allWorkspaces, setActiveWorkspace], - ); - - const handlePrevWorkspace = useCallback(() => { - if (!activeWorkspaceId) return; - const currentIndex = allWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, - ); - if (currentIndex > 0) { - setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex - 1].id }); - } - }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); - - const handleNextWorkspace = useCallback(() => { - if (!activeWorkspaceId) return; - const currentIndex = allWorkspaces.findIndex( - (w) => w.id === activeWorkspaceId, - ); - if (currentIndex < allWorkspaces.length - 1) { - setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex + 1].id }); - } - }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); - - useHotkeys(workspaceKeys, handleWorkspaceSwitch); - useHotkeys(HOTKEYS.PREV_WORKSPACE.keys, handlePrevWorkspace); - useHotkeys(HOTKEYS.NEXT_WORKSPACE.keys, handleNextWorkspace); - useEffect(() => { const checkScroll = () => { if (!scrollRef.current) return; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx index d5f1132cc2d..2441e996563 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -1,25 +1,49 @@ +import type { NavigationStyle } from "@superset/local-db"; import { trpc } from "renderer/lib/trpc"; import { AvatarDropdown } from "../AvatarDropdown"; import { SidebarControl } from "./SidebarControl"; import { WindowControls } from "./WindowControls"; +import { WorkspaceSidebarControl } from "./WorkspaceSidebarControl"; import { WorkspacesTabs } from "./WorkspaceTabs"; -export function TopBar() { +interface TopBarProps { + navigationStyle?: NavigationStyle; +} + +export function TopBar({ navigationStyle = "top-bar" }: TopBarProps) { const { data: platform } = trpc.window.getPlatform.useQuery(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const isMac = platform === "darwin"; + const isSidebarMode = navigationStyle === "sidebar"; + return ( -
+
+ {isSidebarMode && }
-
- -
+ + {isSidebarMode ? ( +
+ {activeWorkspace && ( + + {activeWorkspace.project?.name ?? "Workspace"} + / + {activeWorkspace.name} + + )} +
+ ) : ( +
+ +
+ )} +
{!isMac && } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx new file mode 100644 index 00000000000..85a1fe3d72a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -0,0 +1,42 @@ +import { cn } from "@superset/ui/utils"; +import { LuChevronDown, LuChevronRight } from "react-icons/lu"; + +interface ProjectHeaderProps { + projectName: string; + projectColor: string; + isCollapsed: boolean; + onToggleCollapse: () => void; + workspaceCount: number; +} + +export function ProjectHeader({ + projectName, + projectColor, + isCollapsed, + onToggleCollapse, + workspaceCount, +}: ProjectHeaderProps) { + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx new file mode 100644 index 00000000000..41a5e5f9e20 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -0,0 +1,76 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { useWorkspaceSidebarStore } from "renderer/stores"; +import { WorkspaceListItem } from "../WorkspaceListItem"; +import { ProjectHeader } from "./ProjectHeader"; + +interface Workspace { + id: string; + projectId: string; + worktreePath: string; + type: "worktree" | "branch"; + branch: string; + name: string; + tabOrder: number; +} + +interface ProjectSectionProps { + projectId: string; + projectName: string; + projectColor: string; + workspaces: Workspace[]; + activeWorkspaceId: string | null; + /** Base index for keyboard shortcuts (0-based) */ + shortcutBaseIndex: number; +} + +export function ProjectSection({ + projectId, + projectName, + projectColor, + workspaces, + activeWorkspaceId, + shortcutBaseIndex, +}: ProjectSectionProps) { + const { isProjectCollapsed, toggleProjectCollapsed } = + useWorkspaceSidebarStore(); + + const isCollapsed = isProjectCollapsed(projectId); + + return ( +
+ toggleProjectCollapsed(projectId)} + workspaceCount={workspaces.length} + /> + + + {!isCollapsed && ( + +
+ {workspaces.map((workspace, index) => ( + + ))} +
+
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts new file mode 100644 index 00000000000..2111af01d6b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/index.ts @@ -0,0 +1,2 @@ +export { ProjectHeader } from "./ProjectHeader"; +export { ProjectSection } from "./ProjectSection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx new file mode 100644 index 00000000000..526fa283d2c --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ResizableWorkspaceSidebar.tsx @@ -0,0 +1,94 @@ +import { cn } from "@superset/ui/utils"; +import { useCallback, useEffect, useRef } from "react"; +import { + MAX_WORKSPACE_SIDEBAR_WIDTH, + MIN_WORKSPACE_SIDEBAR_WIDTH, + useWorkspaceSidebarStore, +} from "renderer/stores"; +import { WorkspaceSidebar } from "./WorkspaceSidebar"; + +export function ResizableWorkspaceSidebar() { + const { isOpen, width, setWidth, isResizing, setIsResizing } = + useWorkspaceSidebarStore(); + + const startXRef = useRef(0); + const startWidthRef = useRef(0); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + startXRef.current = e.clientX; + startWidthRef.current = width; + setIsResizing(true); + }, + [width, setIsResizing], + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; + + const delta = e.clientX - startXRef.current; + const newWidth = startWidthRef.current + delta; + const clampedWidth = Math.max( + MIN_WORKSPACE_SIDEBAR_WIDTH, + Math.min(MAX_WORKSPACE_SIDEBAR_WIDTH, newWidth), + ); + setWidth(clampedWidth); + }, + [isResizing, setWidth], + ); + + const handleMouseUp = useCallback(() => { + if (isResizing) { + setIsResizing(false); + } + }, [isResizing, setIsResizing]); + + useEffect(() => { + if (isResizing) { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = "none"; + document.body.style.cursor = "col-resize"; + } + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.userSelect = ""; + document.body.style.cursor = ""; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); + + if (!isOpen) { + return null; + } + + return ( +
+ + + {/* Resize handle */} + {/* biome-ignore lint/a11y/useSemanticElements:
is not appropriate for interactive resize handles */} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx new file mode 100644 index 00000000000..a2758d40b01 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceDiffStats.tsx @@ -0,0 +1,16 @@ +interface WorkspaceDiffStatsProps { + additions: number; + deletions: number; +} + +export function WorkspaceDiffStats({ + additions, + deletions, +}: WorkspaceDiffStatsProps) { + return ( +
+ +{additions} + -{deletions} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx new file mode 100644 index 00000000000..a819fbb3413 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -0,0 +1,104 @@ +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { LuGitBranch } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { useSetActiveWorkspace } from "renderer/react-query/workspaces"; +import { WorkspaceDiffStats } from "./WorkspaceDiffStats"; +import { WorkspaceStatusBadge } from "./WorkspaceStatusBadge"; + +interface WorkspaceListItemProps { + id: string; + name: string; + branch: string; + type: "worktree" | "branch"; + isActive: boolean; + shortcutIndex?: number; +} + +export function WorkspaceListItem({ + id, + name, + branch, + type, + isActive, + shortcutIndex, +}: WorkspaceListItemProps) { + const setActiveWorkspace = useSetActiveWorkspace(); + const [hasHovered, setHasHovered] = useState(false); + + // Lazy-load GitHub status on hover to avoid N+1 queries + const { data: githubStatus } = trpc.workspaces.getGitHubStatus.useQuery( + { workspaceId: id }, + { + enabled: hasHovered && type === "worktree", + staleTime: 30_000, + }, + ); + + const handleClick = () => { + setActiveWorkspace.mutate({ id }); + }; + + const handleMouseEnter = () => { + if (!hasHovered) { + setHasHovered(true); + } + }; + + const pr = githubStatus?.pr; + const showDiffStats = pr && (pr.additions > 0 || pr.deletions > 0); + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx new file mode 100644 index 00000000000..d6eb509d730 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceStatusBadge.tsx @@ -0,0 +1,53 @@ +import { cn } from "@superset/ui/utils"; +import { LuCircleDot, LuGitMerge, LuGitPullRequest } from "react-icons/lu"; + +type PRState = "open" | "merged" | "closed" | "draft"; + +interface WorkspaceStatusBadgeProps { + state: PRState; + prNumber?: number; +} + +export function WorkspaceStatusBadge({ + state, + prNumber, +}: WorkspaceStatusBadgeProps) { + const iconClass = "w-3 h-3"; + + const config = { + open: { + icon: , + bgColor: "bg-emerald-500/10", + }, + merged: { + icon: , + bgColor: "bg-purple-500/10", + }, + closed: { + icon: , + bgColor: "bg-destructive/10", + }, + draft: { + icon: ( + + ), + bgColor: "bg-muted", + }, + }; + + const { icon, bgColor } = config[state]; + + return ( +
+ {icon} + {prNumber && ( + #{prNumber} + )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts new file mode 100644 index 00000000000..4dd9ef18a6e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/index.ts @@ -0,0 +1,3 @@ +export { WorkspaceDiffStats } from "./WorkspaceDiffStats"; +export { WorkspaceListItem } from "./WorkspaceListItem"; +export { WorkspaceStatusBadge } from "./WorkspaceStatusBadge"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx new file mode 100644 index 00000000000..c4d68290bfb --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -0,0 +1,45 @@ +import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; +import { ProjectSection } from "./ProjectSection"; +import { WorkspaceSidebarFooter } from "./WorkspaceSidebarFooter"; +import { WorkspaceSidebarHeader } from "./WorkspaceSidebarHeader"; + +export function WorkspaceSidebar() { + const { groups, activeWorkspaceId } = useWorkspaceShortcuts(); + + // Calculate shortcut base indices for each project group + let shortcutIndex = 0; + const projectShortcutIndices = groups.map((group) => { + const baseIndex = shortcutIndex; + shortcutIndex += group.workspaces.length; + return baseIndex; + }); + + return ( +
+ + +
+ {groups.map((group, index) => ( + + ))} + + {groups.length === 0 && ( +
+ No workspaces yet + Add a project to get started +
+ )} +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx new file mode 100644 index 00000000000..99c4117a96d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarFooter.tsx @@ -0,0 +1,62 @@ +import { Button } from "@superset/ui/button"; +import { toast } from "@superset/ui/sonner"; +import { LuFolderPlus } from "react-icons/lu"; +import { useOpenNew } from "renderer/react-query/projects"; +import { useCreateBranchWorkspace } from "renderer/react-query/workspaces"; + +export function WorkspaceSidebarFooter() { + const openNew = useOpenNew(); + const createBranchWorkspace = useCreateBranchWorkspace(); + + const handleOpenNewProject = async () => { + try { + const result = await openNew.mutateAsync(undefined); + if (result.canceled) { + return; + } + if ("error" in result) { + toast.error("Failed to open project", { + description: result.error, + }); + return; + } + if ("needsGitInit" in result) { + toast.error("Selected folder is not a git repository", { + description: + "Please use 'Open project' from the start view to initialize git.", + }); + return; + } + // Create a main workspace on the current branch for the new project + toast.promise( + createBranchWorkspace.mutateAsync({ projectId: result.project.id }), + { + loading: "Opening project...", + success: "Project opened", + error: (err) => + err instanceof Error ? err.message : "Failed to open project", + }, + ); + } catch (error) { + toast.error("Failed to open project", { + description: + error instanceof Error ? error.message : "An unknown error occurred", + }); + } + }; + + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx new file mode 100644 index 00000000000..bc54cd8e98b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx @@ -0,0 +1,10 @@ +import { LuLayers } from "react-icons/lu"; + +export function WorkspaceSidebarHeader() { + return ( +
+ + Workspaces +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts new file mode 100644 index 00000000000..d8dc226739d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/index.ts @@ -0,0 +1,2 @@ +export { ResizableWorkspaceSidebar } from "./ResizableWorkspaceSidebar"; +export { WorkspaceSidebar } from "./WorkspaceSidebar"; diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index ec040bcb367..3df94482574 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -17,6 +17,8 @@ import { useTabsStore } from "renderer/stores/tabs/store"; import type { Tab } from "renderer/stores/tabs/types"; import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener"; import { findPanePath, getFirstPaneId } from "renderer/stores/tabs/utils"; +import { useWorkspaceSidebarStore } from "renderer/stores/workspace-sidebar-state"; +import { DEFAULT_NAVIGATION_STYLE } from "shared/constants"; import { HOTKEYS } from "shared/hotkeys"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; @@ -25,6 +27,7 @@ import { SettingsView } from "./components/SettingsView"; import { StartView } from "./components/StartView"; import { TasksView } from "./components/TasksView"; import { TopBar } from "./components/TopBar"; +import { ResizableWorkspaceSidebar } from "./components/WorkspaceSidebar"; import { WorkspaceView } from "./components/WorkspaceView"; function LoadingSpinner() { @@ -48,9 +51,15 @@ export function MainScreen() { const currentView = useCurrentView(); const openSettings = useOpenSettings(); const { toggleSidebar } = useSidebarStore(); + const { toggleOpen: toggleWorkspaceSidebar } = useWorkspaceSidebarStore(); const hasTasksAccess = useFeatureFlagEnabled( FEATURE_FLAGS.ELECTRIC_TASKS_ACCESS, ); + + // Navigation style setting + const { data: navigationStyle } = trpc.settings.getNavigationStyle.useQuery(); + const effectiveNavigationStyle = navigationStyle ?? DEFAULT_NAVIGATION_STYLE; + const isSidebarMode = effectiveNavigationStyle === "sidebar"; const { data: activeWorkspace, isLoading: isWorkspaceLoading, @@ -96,6 +105,10 @@ export function MainScreen() { if (isWorkspaceView) toggleSidebar(); }, [toggleSidebar, isWorkspaceView]); + useHotkeys(HOTKEYS.TOGGLE_WORKSPACE_SIDEBAR.keys, () => { + if (isSidebarMode) toggleWorkspaceSidebar(); + }, [toggleWorkspaceSidebar, isSidebarMode]); + /** * Resolves the target pane for split operations. * If the focused pane is desynced from layout (e.g., was removed), @@ -269,8 +282,11 @@ export function MainScreen() { ) : (
- -
{renderContent()}
+ +
+ {isSidebarMode && } + {renderContent()} +
)} diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index a34b10458f6..5dc321e9025 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -5,4 +5,5 @@ export * from "./ringtone"; export * from "./sidebar-state"; export * from "./tabs"; export * from "./theme"; +export * from "./workspace-sidebar-state"; export * from "./workspace-view-mode"; diff --git a/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts new file mode 100644 index 00000000000..5093f7d3997 --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts @@ -0,0 +1,97 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +const DEFAULT_WORKSPACE_SIDEBAR_WIDTH = 280; +export const MIN_WORKSPACE_SIDEBAR_WIDTH = 220; +export const MAX_WORKSPACE_SIDEBAR_WIDTH = 400; + +interface WorkspaceSidebarState { + isOpen: boolean; + width: number; + lastOpenWidth: number; + // Use string[] instead of Set for JSON serialization with Zustand persist + collapsedProjectIds: string[]; + isResizing: boolean; + + toggleOpen: () => void; + setOpen: (open: boolean) => void; + setWidth: (width: number) => void; + setIsResizing: (isResizing: boolean) => void; + toggleProjectCollapsed: (projectId: string) => void; + isProjectCollapsed: (projectId: string) => boolean; +} + +export const useWorkspaceSidebarStore = create()( + devtools( + persist( + (set, get) => ({ + isOpen: true, + width: DEFAULT_WORKSPACE_SIDEBAR_WIDTH, + lastOpenWidth: DEFAULT_WORKSPACE_SIDEBAR_WIDTH, + collapsedProjectIds: [], + isResizing: false, + + toggleOpen: () => { + const { isOpen, lastOpenWidth } = get(); + if (isOpen) { + set({ isOpen: false, width: 0 }); + } else { + set({ + isOpen: true, + width: lastOpenWidth, + }); + } + }, + + setOpen: (open) => { + const { lastOpenWidth } = get(); + set({ + isOpen: open, + width: open ? lastOpenWidth : 0, + }); + }, + + setWidth: (width) => { + const clampedWidth = Math.max( + MIN_WORKSPACE_SIDEBAR_WIDTH, + Math.min(MAX_WORKSPACE_SIDEBAR_WIDTH, width), + ); + + if (width > 0) { + set({ + width: clampedWidth, + lastOpenWidth: clampedWidth, + isOpen: true, + }); + } else { + set({ + width: 0, + isOpen: false, + }); + } + }, + + setIsResizing: (isResizing) => { + set({ isResizing }); + }, + + toggleProjectCollapsed: (projectId) => { + set((state) => ({ + collapsedProjectIds: state.collapsedProjectIds.includes(projectId) + ? state.collapsedProjectIds.filter((id) => id !== projectId) + : [...state.collapsedProjectIds, projectId], + })); + }, + + isProjectCollapsed: (projectId) => { + return get().collapsedProjectIds.includes(projectId); + }, + }), + { + name: "workspace-sidebar-store", + version: 1, + }, + ), + { name: "WorkspaceSidebarStore" }, + ), +); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 1ec904ae1fc..35c6207de41 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -47,3 +47,4 @@ export const NOTIFICATION_EVENTS = { // Default user preference values export const DEFAULT_CONFIRM_ON_QUIT = true; export const DEFAULT_TERMINAL_LINK_BEHAVIOR = "external-editor" as const; +export const DEFAULT_NAVIGATION_STYLE = "top-bar" as const; diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index f9d07eee468..3d064680c93 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -123,7 +123,12 @@ export const HOTKEYS = { // Layout TOGGLE_SIDEBAR: hotkey({ keys: "meta+b", - label: "Toggle Sidebar", + label: "Toggle Files Sidebar", + category: "Layout", + }), + TOGGLE_WORKSPACE_SIDEBAR: hotkey({ + keys: "meta+shift+b", + label: "Toggle Workspaces Sidebar", category: "Layout", }), SPLIT_RIGHT: hotkey({ diff --git a/packages/local-db/drizzle/0005_add_navigation_style.sql b/packages/local-db/drizzle/0005_add_navigation_style.sql new file mode 100644 index 00000000000..c3c175a0327 --- /dev/null +++ b/packages/local-db/drizzle/0005_add_navigation_style.sql @@ -0,0 +1 @@ +ALTER TABLE `settings` ADD `navigation_style` text; \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0005_snapshot.json b/packages/local-db/drizzle/meta/0005_snapshot.json new file mode 100644 index 00000000000..14c02c328fd --- /dev/null +++ b/packages/local-db/drizzle/meta/0005_snapshot.json @@ -0,0 +1,984 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ac200b80-657f-4cd7-b338-2d6adeb925e7", + "prevId": "bb9f9f85-bcbb-4003-b20f-4c172a1c6fc8", + "tables": { + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_app": { + "name": "last_used_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "navigation_style": { + "name": "navigation_style", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 65dc59e762b..ee8d7e16496 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1767166138761, "tag": "0004_add_terminal_link_behavior_setting", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1767166547886, + "tag": "0005_add_navigation_style", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index 0f98d17ada9..c708a32964f 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -112,6 +112,11 @@ export const workspaces = sqliteTable( export type InsertWorkspace = typeof workspaces.$inferInsert; export type SelectWorkspace = typeof workspaces.$inferSelect; +/** + * Navigation style for workspace display + */ +export type NavigationStyle = "top-bar" | "sidebar"; + /** * Settings table - single row with typed columns */ @@ -131,6 +136,7 @@ export const settings = sqliteTable("settings", { terminalLinkBehavior: text( "terminal_link_behavior", ).$type(), + navigationStyle: text("navigation_style").$type(), }); export type InsertSettings = typeof settings.$inferInsert;