diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6658b40d9..696bb9f33 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -99,7 +99,6 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.3", - "react-hotkeys-hook": "^5.2.1", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-mosaic-component": "^6.1.1", diff --git a/apps/desktop/plans/done/20260104-1916-restore-group-tabs-sidebar.md b/apps/desktop/plans/done/20260104-1916-restore-group-tabs-sidebar.md new file mode 100644 index 000000000..7ee522ee1 --- /dev/null +++ b/apps/desktop/plans/done/20260104-1916-restore-group-tabs-sidebar.md @@ -0,0 +1,646 @@ +# Restore Group Tabs to Sidebar with Configurable Position + +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. + +Reference: This plan follows conventions from AGENTS.md and the ExecPlan template. + +## Purpose / Big Picture + +PR #586 accidentally moved tab groups (the "Group 1", "Group 2" tabs that organize terminals/panes within a workspace) from their original location in the left sidebar to a horizontal strip in the content header. This is a breaking change from the behavior on the `main` branch. + +After this change: +- **Default behavior** matches `main`: tab groups appear in the left sidebar via `ModeCarousel` + `TabsView` +- **New option**: users can choose to display groups in the content header (horizontal `GroupStrip`) via Settings +- The Workbench/Review mode toggle continues to work correctly with either setting + +To verify: Open Settings → Behavior → "Group tabs position" dropdown. Switching between "Sidebar" and "Content header" immediately changes where groups are displayed. + +## Assumptions + +1. The `ModeCarousel` and `TabsView` components still exist on the `main` branch and can be restored via git checkout +2. The existing `navigationStyle` setting pattern is the correct template to follow +3. Feature parity between `TabsView` (sidebar) and `GroupStrip` (header) is NOT required - they can have different capabilities (TabsView has rename/reorder/presets; GroupStrip is a compact switcher) +4. Review mode must always show the changes/files view regardless of group tabs position setting +5. The current Sidebar passes `onFileOpen` to `ChangesView` for FileViewerPane integration - this must be preserved + +## Open Questions + +None - all questions resolved during planning phase. + +## Progress + +- [x] Milestone 1: Database schema and tRPC procedures +- [x] Milestone 2: Restore sidebar components from main (if missing) — SKIPPED (components already existed) +- [x] Milestone 3: Conditional rendering logic +- [x] Milestone 4: Settings UI +- [x] Milestone 5: Validation and QA + +## Surprises & Discoveries + +- `TabsView` and `ModeCarousel` directories already existed in the branch, so Milestone 2 was skipped +- The `generate` script in local-db is just `generate`, not `db:generate` as originally documented in plan +- Biome auto-fixed one formatting issue in BehaviorSettings.tsx (long line wrapping) + +## Decision Log + +- Decision: Default to "sidebar" position + Rationale: Matches existing behavior on main branch, minimizes breaking change + Date/Author: 2026-01-04 / User + +- Decision: Feature parity NOT required between TabsView and GroupStrip + Rationale: They serve different UX needs; TabsView has rename/reorder/presets, GroupStrip is compact switcher. Settings UI should clarify this tradeoff. + Date/Author: 2026-01-04 / Planning + +- Decision: Review mode always shows ChangesView regardless of setting + Rationale: Review mode requires file list to function; showing tabs would break UX + Date/Author: 2026-01-04 / Oracle review + +- Decision: Use drizzle-kit generate for migration (not hand-create SQL) + Rationale: Desktop app migrator requires `drizzle/meta/_journal.json` to be updated; hand-created SQL won't run + Date/Author: 2026-01-04 / Oracle review + +## Outcomes & Retrospective + +**Completion Date**: 2026-01-04 + +**Summary**: Successfully implemented configurable group tabs position with default "sidebar" to match main branch behavior. The feature: +- Adds new `groupTabsPosition` setting with options: "sidebar" (default) or "content-header" +- Restores ModeCarousel + TabsView in sidebar when position is "sidebar" +- Shows GroupStrip in content header when position is "content-header" +- Properly handles Review mode (always shows ChangesView, never tabs) +- Includes Settings UI with description of feature differences between modes + +**Files Changed**: +- `packages/local-db/src/schema/schema.ts` - Added GroupTabsPosition type and column +- `packages/local-db/drizzle/0008_add_group_tabs_position.sql` - Migration (generated) +- `apps/desktop/src/shared/constants.ts` - Added DEFAULT_GROUP_TABS_POSITION +- `apps/desktop/src/lib/trpc/routers/settings/index.ts` - Added getter/setter procedures +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx` - Conditional ModeCarousel rendering +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx` - Conditional GroupStrip rendering +- `apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx` - Settings dropdown + +**What Went Well**: +- Oracle review caught critical issues upfront (migration workflow, viewMode handling) +- Existing patterns (navigationStyle) provided clear template to follow +- Components already existed, reducing scope + +**What Could Be Improved**: +- Plan's script name was wrong (`db:generate` vs `generate`) - should verify commands before documenting + +## Context and Orientation + +### Apps and Packages Affected +- **App**: `apps/desktop` (Electron desktop application) +- **Packages**: `packages/local-db` (SQLite schema and migrations) + +### Key Concepts + +**ModeCarousel**: A swipeable carousel component in the left sidebar that switches between different views. On `main`, it has two modes: "Tabs" (showing tab groups) and "Changes" (showing git changes/files). + +**TabsView**: The component rendered when ModeCarousel is in "Tabs" mode. Shows a vertical list of tab groups with features like rename, drag-and-drop reorder, and terminal presets. + +**GroupStrip**: A new horizontal component added in PR #586 that shows tab groups in the content header area. More compact but fewer features than TabsView. + +**viewMode**: The Workbench/Review toggle state. "workbench" shows the mosaic panes layout; "review" shows the dedicated changes page. + +**onFileOpen**: A callback prop passed to `ChangesView` that opens files in `FileViewerPane` when in workbench mode. Must be preserved in all Sidebar rendering paths. + +### Current State (PR #586 branch) + +The sidebar (`apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx`) no longer uses `ModeCarousel`. It renders `ChangesView` with `onFileOpen` prop for workbench mode integration. + +The `ContentView` (`apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx`) renders `GroupStrip` in a `ContentHeader` wrapper. It passes `workspaceId` and `worktreePath` to `WorkspaceControls`. + +### Target State (after this plan) + +The sidebar conditionally renders based on BOTH `viewMode` AND `groupTabsPosition`: +1. **Review mode (any position)**: Always show `ChangesView` only +2. **Workbench + content-header**: Show `ChangesView` with `onFileOpen` +3. **Workbench + sidebar**: Show `ModeCarousel` with Tabs/Changes modes + +The `ContentView` conditionally renders: +1. **Review mode**: No `ContentHeader` with GroupStrip +2. **Workbench + content-header**: `ContentHeader` with `GroupStrip` +3. **Workbench + sidebar + top-bar nav**: No `ContentHeader` (avoid empty header) +4. **Workbench + sidebar + sidebar nav**: `ContentHeader` with controls only (no GroupStrip) + +### File Inventory + +Files to create: +- Migration file (generated by drizzle-kit, name TBD like `0008_add_group_tabs_position.sql`) + +Files to modify: +- `packages/local-db/src/schema/schema.ts` +- `apps/desktop/src/shared/constants.ts` +- `apps/desktop/src/lib/trpc/routers/settings/index.ts` +- `apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx` +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx` + +Files to restore from `main` (only if missing/different): +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/` (entire directory) +- `apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/` (entire directory) + +## Plan of Work + +### Milestone 1: Database Schema and tRPC Procedures + +Add the new setting to the database schema and expose it via tRPC. + +**Step 1.1: Add type to schema** + +In `packages/local-db/src/schema/schema.ts`, add after the `NavigationStyle` type: + +```typescript +export type GroupTabsPosition = "sidebar" | "content-header"; +``` + +In the same file, add to the `settings` table definition: + +```typescript +groupTabsPosition: text("group_tabs_position").$type(), +``` + +**Step 1.2: Generate migration with drizzle-kit** + +IMPORTANT: Do NOT hand-create the migration file. The desktop app's migrator requires `drizzle/meta/_journal.json` to be updated. + +```bash +cd packages/local-db +bun run db:generate --name add_group_tabs_position +``` + +This will: +- Create `drizzle/XXXX_add_group_tabs_position.sql` with the ALTER TABLE statement +- Update `drizzle/meta/_journal.json` with the new migration entry +- Update `drizzle/meta/XXXX_snapshot.json` with the new schema snapshot + +Verify the generated SQL contains: +```sql +ALTER TABLE settings ADD COLUMN group_tabs_position TEXT; +``` + +**Step 1.3: Add default constant** + +In `apps/desktop/src/shared/constants.ts`, add: + +```typescript +export const DEFAULT_GROUP_TABS_POSITION = "sidebar" as const; +``` + +**Step 1.4: Add tRPC procedures** + +In `apps/desktop/src/lib/trpc/routers/settings/index.ts`: + +Add import at top: +```typescript +import { DEFAULT_GROUP_TABS_POSITION } from "shared/constants"; +``` + +Note: Use `shared/constants` NOT `@shared/constants` (the @ alias doesn't exist in this codebase). + +Add getter and setter following the `navigationStyle` pattern: + +```typescript +getGroupTabsPosition: publicProcedure.query(() => { + const row = getSettings(); + return row.groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; +}), + +setGroupTabsPosition: publicProcedure + .input(z.object({ position: z.enum(["sidebar", "content-header"]) })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, groupTabsPosition: input.position }) + .onConflictDoUpdate({ + target: settings.id, + set: { groupTabsPosition: input.position }, + }) + .run(); + + return { success: true }; + }), +``` + +### Milestone 2: Restore Sidebar Components from Main (if needed) + +Check if `TabsView` and `ModeCarousel` exist in the current branch. If missing or different from main, restore them. + +**Step 2.1: Check if restoration is needed** + +```bash +cd /Users/andreasasprou/.superset/worktrees/superset/workspacesidebar + +# Check if TabsView exists +ls apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/ 2>/dev/null || echo "TabsView MISSING - needs restore" + +# Check if ModeCarousel exists +ls apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/ 2>/dev/null || echo "ModeCarousel MISSING - needs restore" +``` + +**Step 2.2: Restore TabsView directory (if missing)** + +```bash +git checkout main -- apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/ +``` + +This restores: +- `TabsView/index.tsx` - Main component +- `TabsView/TabItem.tsx` - Individual tab item +- `TabsView/PortsList.tsx` - Port forwarding list +- `TabsView/PresetContextMenu.tsx` - Terminal presets menu +- `TabsView/TabsCommandDialog.tsx` - Command palette for tabs + +**Step 2.3: Restore ModeCarousel directory (if missing)** + +```bash +git checkout main -- apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/ +``` + +This restores: +- `ModeCarousel/index.tsx` - Main carousel component +- `ModeCarousel/ModeHeader.tsx` - Header for each mode +- `ModeCarousel/ModeContent.tsx` - Content wrapper +- `ModeCarousel/ModeNavigation.tsx` - Navigation dots +- `ModeCarousel/types.ts` - Type definitions +- `ModeCarousel/hooks/` - Custom hooks for carousel behavior + +**Step 2.4: Verify restoration and check for API drift** + +After restoration, run typecheck to catch any import/API mismatches: + +```bash +bun run typecheck --filter=@superset/desktop +``` + +If there are errors, they indicate API drift that must be resolved before proceeding. + +### Milestone 3: Conditional Rendering Logic + +Update the Sidebar and ContentView to conditionally render based on the setting AND viewMode. + +**Step 3.1: Update Sidebar** + +Read the current `apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx` first to understand existing props and structure. + +The updated Sidebar must: +1. Check `viewMode` FIRST - Review mode always shows ChangesView only +2. Preserve `onFileOpen` prop passing to `ChangesView` +3. Conditionally show ModeCarousel vs ChangesView based on `groupTabsPosition` + +```typescript +import { trpc } from "renderer/lib/trpc"; +import { DEFAULT_GROUP_TABS_POSITION } from "shared/constants"; +import { SidebarMode, useSidebarStore } from "renderer/stores"; +import { useViewModeStore } from "renderer/stores/workspace-view-mode"; +import { ChangesView } from "./ChangesView"; +import { ModeCarousel } from "./ModeCarousel"; +import { TabsView } from "./TabsView"; + +interface SidebarProps { + onFileOpen?: (filePath: string, options?: { line?: number; column?: number }) => void; +} + +export function Sidebar({ onFileOpen }: SidebarProps) { + const { data: groupTabsPosition } = trpc.settings.getGroupTabsPosition.useQuery(); + const effectivePosition = groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; + + const viewMode = useViewModeStore((s) => s.viewMode); + const { currentMode, setMode } = useSidebarStore(); + + // CRITICAL: Review mode ALWAYS shows ChangesView only, regardless of setting + // This ensures the file list is always available for review + if (viewMode === "review") { + return ( + + ); + } + + // Workbench mode with groups in content header: only show ChangesView + if (effectivePosition === "content-header") { + return ( + + ); + } + + // Workbench mode with groups in sidebar: show ModeCarousel with Tabs/Changes + const modes: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; + + return ( + + ); +} +``` + +**Step 3.2: Update ContentView** + +Read the current `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx` first to understand existing structure and props. + +Key changes: +1. Add query for `groupTabsPosition` +2. Only show GroupStrip when `viewMode === "workbench"` AND `groupTabsPosition === "content-header"` +3. Only render ContentHeader when needed (avoid empty header in top-bar mode with sidebar groups) +4. Preserve existing props to WorkspaceControls (`workspaceId`, `worktreePath`) + +The logic for whether to show ContentHeader: + +```typescript +const { data: groupTabsPosition } = trpc.settings.getGroupTabsPosition.useQuery(); +const effectivePosition = groupTabsPosition ?? DEFAULT_GROUP_TABS_POSITION; + +// Show GroupStrip only in workbench mode with content-header position +const showGroupStrip = viewMode === "workbench" && effectivePosition === "content-header"; + +// Show ContentHeader if: +// 1. In sidebar navigation mode (needs SidebarControl and WorkspaceControls), OR +// 2. GroupStrip should be shown +const showContentHeader = isSidebarMode || showGroupStrip; +``` + +In the JSX: + +```typescript +{showContentHeader && ( + : undefined} + trailingAction={ + isSidebarMode ? ( + + ) : undefined + } + > + {showGroupStrip ? :
} + +)} +``` + +### Milestone 4: Settings UI + +Add the dropdown to the Behavior settings page. + +**Step 4.1: Update BehaviorSettings** + +In `apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx`: + +Add import: +```typescript +import { DEFAULT_GROUP_TABS_POSITION } from "shared/constants"; +``` + +Add the query and mutation (following existing patterns): + +```typescript +const { data: groupTabsPosition, isLoading: isGroupTabsLoading } = + trpc.settings.getGroupTabsPosition.useQuery(); + +const setGroupTabsPositionMutation = trpc.settings.setGroupTabsPosition.useMutation({ + onMutate: async ({ position }) => { + await utils.settings.getGroupTabsPosition.cancel(); + const previous = utils.settings.getGroupTabsPosition.getData(); + utils.settings.getGroupTabsPosition.setData(undefined, position); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous) { + utils.settings.getGroupTabsPosition.setData(undefined, context.previous); + } + }, + onSettled: () => { + utils.settings.getGroupTabsPosition.invalidate(); + }, +}); +``` + +Add the Select component in the JSX (after the Navigation style select): + +```tsx +
+
+ +

+ Where to display terminal group tabs. Sidebar includes rename, reorder, and presets; header is compact. +

+
+ +
+``` + +Note: Disable the select during BOTH loading AND mutation pending states. + +### Milestone 5: Validation and QA + +Run all validation commands and test the feature matrix. + +## Concrete Steps + +### After Milestone 1: + +```bash +cd /Users/andreasasprou/.superset/worktrees/superset/workspacesidebar + +# Verify migration was generated +ls packages/local-db/drizzle/*.sql | tail -1 +# Expected: Shows the new migration file + +# Check journal was updated +cat packages/local-db/drizzle/meta/_journal.json | tail -20 +# Expected: Shows entry for new migration + +# Typecheck local-db +bun run typecheck --filter=@superset/local-db +# Expected: No errors +``` + +### After Milestone 2: + +```bash +cd /Users/andreasasprou/.superset/worktrees/superset/workspacesidebar + +# Verify directories exist +ls -la apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/ +ls -la apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ModeCarousel/ +# Expected: Both directories exist with their files + +# Check for API drift +bun run typecheck --filter=@superset/desktop +# Expected: No errors (or document any that need fixing) +``` + +### After Milestone 4: + +```bash +cd /Users/andreasasprou/.superset/worktrees/superset/workspacesidebar + +bun run typecheck +# Expected: No type errors + +bun run lint:fix +# Expected: Fixes applied, no remaining errors +``` + +### Final Validation: + +```bash +cd /Users/andreasasprou/.superset/worktrees/superset/workspacesidebar +bun dev +# Expected: Desktop app opens +``` + +## Validation and Acceptance + +### QA Test Matrix + +Test all combinations: + +| navigationStyle | groupTabsPosition | viewMode | Expected Behavior | +|----------------|-------------------|----------|-------------------| +| top-bar | sidebar | workbench | Groups in left sidebar via ModeCarousel, no ContentHeader | +| top-bar | sidebar | review | ChangesView only (no ModeCarousel, no group tabs) | +| top-bar | content-header | workbench | GroupStrip in content header | +| top-bar | content-header | review | No GroupStrip, no ContentHeader | +| sidebar | sidebar | workbench | Groups in left sidebar via ModeCarousel, ContentHeader with controls | +| sidebar | sidebar | review | ChangesView only, ContentHeader with controls | +| sidebar | content-header | workbench | GroupStrip in content header, ContentHeader with controls | +| sidebar | content-header | review | No GroupStrip, ContentHeader with controls only | + +### Additional QA Checks + +**Upgrade existing DB:** +1. Start app with existing database (has settings but no group_tabs_position column) +2. Migration should run automatically +3. Setting should default to "sidebar" +4. No crash on startup + +**GroupStrip functional checks (when in content-header mode):** +1. Click group tab to switch +2. Click + to add new group +3. Click × to close group +4. Verify groups persist after restart + +**TabsView functional checks (when in sidebar mode):** +1. Double-click tab to rename +2. Drag tab to reorder +3. Right-click for context menu +4. Terminal presets work + +### Acceptance Criteria + +1. Settings → Behavior shows "Group tabs position" dropdown +2. Default is "Sidebar" (matching main branch behavior) +3. Changing setting immediately updates UI (no restart needed) +4. Setting persists after app restart +5. In Review mode, groups are NEVER shown (regardless of setting) +6. ModeCarousel swipe gesture works when groups are in sidebar +7. TabsView features work: rename tab, drag reorder, terminal presets +8. Existing databases upgrade without crash +9. `onFileOpen` continues to work in workbench mode (files open in FileViewerPane) + +## Idempotence and Recovery + +All steps are idempotent: +- Schema changes use `onConflictDoUpdate` pattern +- Git checkout commands can be re-run safely +- drizzle-kit generate can be re-run (will no-op if already done) + +If something goes wrong: +- **Database (dev)**: Delete `~/.superset-dev/local.db` to reset (loses local settings) +- **Database (prod)**: Delete `~/.superset/local.db` to reset +- **Git**: `git checkout -- ` to restore any file to branch state +- **Migration issues**: Check `packages/local-db/drizzle/meta/_journal.json` is updated + +## Interfaces and Dependencies + +### New tRPC Procedures + +```typescript +// In apps/desktop/src/lib/trpc/routers/settings/index.ts + +getGroupTabsPosition: publicProcedure.query(() => GroupTabsPosition) + +setGroupTabsPosition: publicProcedure + .input(z.object({ position: z.enum(["sidebar", "content-header"]) })) + .mutation(() => { success: boolean }) +``` + +### New Types + +```typescript +// In packages/local-db/src/schema/schema.ts +export type GroupTabsPosition = "sidebar" | "content-header"; +``` + +### New Constants + +```typescript +// In apps/desktop/src/shared/constants.ts +export const DEFAULT_GROUP_TABS_POSITION = "sidebar" as const; +``` + +### Existing Dependencies Used + +- `useViewModeStore` from `renderer/stores/workspace-view-mode` - for checking workbench vs review +- `useSidebarStore` from `renderer/stores/sidebar-state` - for ModeCarousel state +- `SidebarMode` enum from `renderer/stores` - Tabs and Changes modes + +## Artifacts and Notes + +### Oracle Review Findings (incorporated into plan) + +1. **Migration workflow**: Must use `bun run db:generate` not hand-create SQL - migrator requires `_journal.json` +2. **Review mode enforcement**: Both Sidebar AND ContentView must check viewMode - tabs never in review +3. **Preserve onFileOpen**: Sidebar passes this to ChangesView for FileViewerPane integration +4. **ContentHeader conditional**: Don't show empty header in top-bar mode with sidebar groups +5. **WorkspaceControls props**: Must pass `workspaceId` and `worktreePath` +6. **Import path**: Use `shared/constants` not `@shared/constants` +7. **Select disabled state**: Disable during loading AND mutation pending +8. **Dev database path**: `~/.superset-dev/local.db` not `~/.superset/local.db` + +--- + +## Revision History + +- 2026-01-04 19:16 - Initial plan created +- 2026-01-04 19:45 - Updated with Oracle review feedback: + - Fixed migration workflow to use drizzle-kit generate + - Added viewMode check to Sidebar (review mode handling) + - Preserved onFileOpen prop in Sidebar + - Fixed ContentView to conditionally show ContentHeader + - Fixed import path (shared/constants not @shared/constants) + - Added check before restoring components in Milestone 2 + - Fixed database path in recovery section + - Enhanced QA with upgrade and functional checks + - Added mutation pending check to Select disabled state diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index bc63ab91a..bda18dccf 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -4,6 +4,11 @@ import { localDb } from "main/lib/local-db"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + assertRegisteredWorktree, + getRegisteredWorktree, + gitSwitchBranch, +} from "./security"; export const createBranchesRouter = () => { return router({ @@ -18,6 +23,8 @@ export const createBranchesRouter = () => { defaultBranch: string; checkedOutBranches: Record; }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const branchSummary = await git.branch(["-a"]); @@ -59,18 +66,8 @@ export const createBranchesRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - - const worktree = localDb - .select() - .from(worktrees) - .where(eq(worktrees.path, input.worktreePath)) - .get(); - if (!worktree) { - throw new Error(`No worktree found at path "${input.worktreePath}"`); - } - - await git.checkout(input.branch); + const worktree = getRegisteredWorktree(input.worktreePath); + await gitSwitchBranch(input.worktreePath, input.branch); // Update the branch in the worktree record const gitStatus = worktree.gitStatus diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index 8af04dd68..8967db3fc 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -1,11 +1,48 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; import type { FileContents } from "shared/changes-types"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + assertRegisteredWorktree, + PathValidationError, + secureFs, +} from "./security"; import { detectLanguage } from "./utils/parse-status"; +/** Maximum file size for reading (2 MiB) */ +const MAX_FILE_SIZE = 2 * 1024 * 1024; + +/** Bytes to scan for binary detection */ +const BINARY_CHECK_SIZE = 8192; + +/** + * Result type for readWorkingFile procedure + */ +type ReadWorkingFileResult = + | { ok: true; content: string; truncated: boolean; byteLength: number } + | { + ok: false; + reason: + | "not-found" + | "too-large" + | "binary" + | "outside-worktree" + | "symlink-escape"; + }; + +/** + * Detects if a buffer contains binary content by checking for NUL bytes + */ +function isBinaryContent(buffer: Buffer): boolean { + const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE); + for (let i = 0; i < checkLength; i++) { + if (buffer[i] === 0) { + return true; + } + } + return false; +} + export const createFileContentsRouter = () => { return router({ getFileContents: publicProcedure @@ -20,6 +57,8 @@ export const createFileContentsRouter = () => { }), ) .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; const originalPath = input.oldPath || input.filePath; @@ -50,10 +89,57 @@ export const createFileContentsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const fullPath = join(input.worktreePath, input.filePath); - await writeFile(fullPath, input.content, "utf-8"); + await secureFs.writeFile( + input.worktreePath, + input.filePath, + input.content, + ); return { success: true }; }), + + /** + * Read a working tree file safely with size cap and binary detection. + * Used for File Viewer raw/rendered modes. + */ + readWorkingFile: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + filePath: z.string(), + }), + ) + .query(async ({ input }): Promise => { + try { + const stats = await secureFs.stat(input.worktreePath, input.filePath); + if (stats.size > MAX_FILE_SIZE) { + return { ok: false, reason: "too-large" }; + } + + const buffer = await secureFs.readFileBuffer( + input.worktreePath, + input.filePath, + ); + + if (isBinaryContent(buffer)) { + return { ok: false, reason: "binary" }; + } + + return { + ok: true, + content: buffer.toString("utf-8"), + truncated: false, + byteLength: buffer.length, + }; + } catch (error) { + if (error instanceof PathValidationError) { + if (error.code === "SYMLINK_ESCAPE") { + return { ok: false, reason: "symlink-escape" }; + } + return { ok: false, reason: "outside-worktree" }; + } + return { ok: false, reason: "not-found" }; + } + }), }); }; @@ -91,26 +177,41 @@ async function getFileVersions( } } +/** Helper to safely get git show content with size limit and memory protection */ +async function safeGitShow( + git: ReturnType, + spec: string, +): Promise { + try { + // Preflight: check blob size before loading into memory + // This prevents memory spikes from large files in git history + try { + const sizeOutput = await git.raw(["cat-file", "-s", spec]); + const blobSize = Number.parseInt(sizeOutput.trim(), 10); + if (!Number.isNaN(blobSize) && blobSize > MAX_FILE_SIZE) { + return `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; + } + } catch { + // cat-file failed (blob doesn't exist) - let git.show handle the error + } + + const content = await git.show([spec]); + return content; + } catch { + return ""; + } +} + async function getAgainstBaseVersions( git: ReturnType, filePath: string, originalPath: string, defaultBranch: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`origin/${defaultBranch}:${originalPath}`]); - } catch { - original = ""; - } - - try { - modified = await git.show([`HEAD:${filePath}`]); - } catch { - modified = ""; - } + const [original, modified] = await Promise.all([ + safeGitShow(git, `origin/${defaultBranch}:${originalPath}`), + safeGitShow(git, `HEAD:${filePath}`), + ]); return { original, modified }; } @@ -121,20 +222,10 @@ async function getCommittedVersions( originalPath: string, commitHash: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`${commitHash}^:${originalPath}`]); - } catch { - original = ""; - } - - try { - modified = await git.show([`${commitHash}:${filePath}`]); - } catch { - modified = ""; - } + const [original, modified] = await Promise.all([ + safeGitShow(git, `${commitHash}^:${originalPath}`), + safeGitShow(git, `${commitHash}:${filePath}`), + ]); return { original, modified }; } @@ -144,20 +235,10 @@ async function getStagedVersions( filePath: string, originalPath: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`HEAD:${originalPath}`]); - } catch { - original = ""; - } - - try { - modified = await git.show([`:0:${filePath}`]); - } catch { - modified = ""; - } + const [original, modified] = await Promise.all([ + safeGitShow(git, `HEAD:${originalPath}`), + safeGitShow(git, `:0:${filePath}`), + ]); return { original, modified }; } @@ -168,22 +249,22 @@ async function getUnstagedVersions( filePath: string, originalPath: string, ): Promise { - let original = ""; - let modified = ""; - - try { - original = await git.show([`:0:${originalPath}`]); - } catch { - try { - original = await git.show([`HEAD:${originalPath}`]); - } catch { - original = ""; - } + // Try staged version first, fall back to HEAD + let original = await safeGitShow(git, `:0:${originalPath}`); + if (!original) { + original = await safeGitShow(git, `HEAD:${originalPath}`); } + let modified = ""; try { - modified = await readFile(join(worktreePath, filePath), "utf-8"); + const stats = await secureFs.stat(worktreePath, filePath); + if (stats.size <= MAX_FILE_SIZE) { + modified = await secureFs.readFile(worktreePath, filePath); + } else { + modified = `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; + } } catch { + // File doesn't exist or validation failed - that's ok for diff display modified = ""; } diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index 6e6f584cf..c69364a34 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -1,10 +1,9 @@ -import { writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; import { shell } from "electron"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { isUpstreamMissingError } from "./git-utils"; +import { assertRegisteredWorktree } from "./security"; export { isUpstreamMissingError }; @@ -21,25 +20,8 @@ async function hasUpstreamBranch( export const createGitOperationsRouter = () => { return router({ - saveFile: publicProcedure - .input( - z.object({ - worktreePath: z.string(), - filePath: z.string(), - content: z.string(), - }), - ) - .mutation(async ({ input }): Promise<{ success: boolean }> => { - const resolvedWorktree = resolve(input.worktreePath); - const fullPath = resolve(resolvedWorktree, input.filePath); - - if (!fullPath.startsWith(`${resolvedWorktree}/`)) { - throw new Error("Invalid file path: path traversal detected"); - } - - await writeFile(fullPath, input.content, "utf-8"); - return { success: true }; - }), + // NOTE: saveFile is defined in file-contents.ts with hardened path validation + // Do NOT add saveFile here - it would overwrite the secure version commit: publicProcedure .input( @@ -50,6 +32,8 @@ export const createGitOperationsRouter = () => { ) .mutation( async ({ input }): Promise<{ success: boolean; hash: string }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const result = await git.commit(input.message); return { success: true, hash: result.commit }; @@ -64,6 +48,8 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const hasUpstream = await hasUpstreamBranch(git); @@ -84,6 +70,8 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); try { await git.pull(["--rebase"]); @@ -107,6 +95,8 @@ export const createGitOperationsRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); try { await git.pull(["--rebase"]); @@ -134,6 +124,8 @@ export const createGitOperationsRouter = () => { ) .mutation( async ({ input }): Promise<{ success: boolean; url: string }> => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); const hasUpstream = await hasUpstreamBranch(git); diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts new file mode 100644 index 000000000..2877176b8 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts @@ -0,0 +1,137 @@ +import simpleGit from "simple-git"; +import { + assertRegisteredWorktree, + assertValidGitPath, +} from "./path-validation"; + +/** + * Git command helpers with semantic naming. + * + * Design principle: Different functions for different git semantics. + * You can't accidentally use file checkout syntax for branch switching. + * + * Each function: + * 1. Validates worktree is registered + * 2. Validates paths/refs as appropriate + * 3. Uses the correct git command syntax + */ + +/** + * Switch to a branch. + * + * Uses `git switch` (unambiguous branch operation, git 2.23+). + * Falls back to `git checkout ` for older git versions. + * + * Note: `git checkout -- ` is WRONG - that's file checkout syntax. + */ +export async function gitSwitchBranch( + worktreePath: string, + branch: string, +): Promise { + assertRegisteredWorktree(worktreePath); + + // Validate: reject anything that looks like a flag + if (branch.startsWith("-")) { + throw new Error("Invalid branch name: cannot start with -"); + } + + // Validate: reject empty branch names + if (!branch.trim()) { + throw new Error("Invalid branch name: cannot be empty"); + } + + const git = simpleGit(worktreePath); + + try { + // Prefer `git switch` - unambiguous branch operation (git 2.23+) + await git.raw(["switch", branch]); + } catch (switchError) { + // Check if it's because `switch` command doesn't exist (old git < 2.23) + // Git outputs: "git: 'switch' is not a git command. See 'git --help'." + const errorMessage = String(switchError); + if (errorMessage.includes("is not a git command")) { + // Fallback for older git versions + // Note: checkout WITHOUT -- is correct for branches + await git.checkout(branch); + } else { + throw switchError; + } + } +} + +/** + * Checkout (restore) a file path, discarding local changes. + * + * Uses `git checkout -- ` - the `--` is REQUIRED here + * to indicate path mode (not branch mode). + */ +export async function gitCheckoutFile( + worktreePath: string, + filePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + assertValidGitPath(filePath); + + const git = simpleGit(worktreePath); + // `--` is correct here - we want path semantics + await git.checkout(["--", filePath]); +} + +/** + * Stage a file for commit. + * + * Uses `git add -- ` - the `--` prevents paths starting + * with `-` from being interpreted as flags. + */ +export async function gitStageFile( + worktreePath: string, + filePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + assertValidGitPath(filePath); + + const git = simpleGit(worktreePath); + await git.add(["--", filePath]); +} + +/** + * Stage all changes for commit. + * + * Uses `git add -A` to stage all changes (new, modified, deleted). + */ +export async function gitStageAll(worktreePath: string): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.add("-A"); +} + +/** + * Unstage a file (remove from staging area). + * + * Uses `git reset HEAD -- ` to unstage without + * discarding changes. + */ +export async function gitUnstageFile( + worktreePath: string, + filePath: string, +): Promise { + assertRegisteredWorktree(worktreePath); + assertValidGitPath(filePath); + + const git = simpleGit(worktreePath); + await git.reset(["HEAD", "--", filePath]); +} + +/** + * Unstage all files. + * + * Uses `git reset HEAD` to unstage all changes without + * discarding them. + */ +export async function gitUnstageAll(worktreePath: string): Promise { + assertRegisteredWorktree(worktreePath); + + const git = simpleGit(worktreePath); + await git.reset(["HEAD"]); +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts new file mode 100644 index 000000000..8fdb09c9e --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts @@ -0,0 +1,31 @@ +/** + * Security module for changes routers. + * + * Security model: + * - PRIMARY: Worktree must be registered in localDb + * - SECONDARY: Paths validated for traversal attempts + * + * See path-validation.ts header for full threat model. + */ + +export { + gitCheckoutFile, + gitStageAll, + gitStageFile, + gitSwitchBranch, + gitUnstageAll, + gitUnstageFile, +} from "./git-commands"; + +export { + assertRegisteredWorktree, + assertValidGitPath, + getRegisteredWorktree, + PathValidationError, + type PathValidationErrorCode, + resolvePathInWorktree, + type ValidatePathOptions, + validateRelativePath, +} from "./path-validation"; + +export { secureFs } from "./secure-fs"; diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts new file mode 100644 index 000000000..317994323 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts @@ -0,0 +1,194 @@ +import { isAbsolute, normalize, resolve, sep } from "node:path"; +import { projects, worktrees } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; + +/** + * Security model for desktop app filesystem access: + * + * THREAT MODEL: + * While a compromised renderer can execute commands via terminal panes, + * the File Viewer presents a distinct threat: malicious repositories can + * contain symlinks that trick users into reading/writing sensitive files + * (e.g., `docs/config.yml` → `~/.bashrc`). Users clicking these links + * don't know they're accessing files outside the repo. + * + * PRIMARY BOUNDARY: assertRegisteredWorktree() + * - Only worktree paths registered in localDb are accessible via tRPC + * - Prevents direct filesystem access to unregistered paths + * + * SECONDARY: validateRelativePath() + * - Rejects absolute paths and ".." traversal segments + * - Defense in depth against path manipulation + * + * SYMLINK PROTECTION (secure-fs.ts): + * - Writes: Block if realpath escapes worktree (prevents accidental overwrites) + * - Reads: Caller can check isSymlinkEscaping() to warn users + */ + +/** + * Security error codes for path validation failures. + */ +export type PathValidationErrorCode = + | "ABSOLUTE_PATH" + | "PATH_TRAVERSAL" + | "UNREGISTERED_WORKTREE" + | "INVALID_TARGET" + | "SYMLINK_ESCAPE"; + +/** + * Error thrown when path validation fails. + * Includes a code for programmatic handling. + */ +export class PathValidationError extends Error { + constructor( + message: string, + public readonly code: PathValidationErrorCode, + ) { + super(message); + this.name = "PathValidationError"; + } +} + +/** + * Validates that a workspace path is registered in localDb. + * This is THE critical security boundary. + * + * Accepts: + * - Worktree paths (from worktrees table) + * - Project mainRepoPath (for branch workspaces that work on the main repo) + * + * @throws PathValidationError if path is not registered + */ +export function assertRegisteredWorktree(workspacePath: string): void { + // Check worktrees table first (most common case) + const worktreeExists = localDb + .select() + .from(worktrees) + .where(eq(worktrees.path, workspacePath)) + .get(); + + if (worktreeExists) { + return; + } + + // Check projects.mainRepoPath for branch workspaces + const projectExists = localDb + .select() + .from(projects) + .where(eq(projects.mainRepoPath, workspacePath)) + .get(); + + if (projectExists) { + return; + } + + throw new PathValidationError( + "Workspace path not registered in database", + "UNREGISTERED_WORKTREE", + ); +} + +/** + * Gets the worktree record if registered. Returns record for updates. + * Only works for actual worktrees, not project mainRepoPath. + * + * @throws PathValidationError if worktree is not registered + */ +export function getRegisteredWorktree( + worktreePath: string, +): typeof worktrees.$inferSelect { + const worktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.path, worktreePath)) + .get(); + + if (!worktree) { + throw new PathValidationError( + "Worktree not registered in database", + "UNREGISTERED_WORKTREE", + ); + } + + return worktree; +} + +/** + * Options for path validation. + */ +export interface ValidatePathOptions { + /** + * Allow empty/root path (resolves to worktree itself). + * Default: false (prevents accidental worktree deletion) + */ + allowRoot?: boolean; +} + +/** + * Validates a relative file path for safety. + * Rejects absolute paths and path traversal attempts. + * + * @throws PathValidationError if path is invalid + */ +export function validateRelativePath( + filePath: string, + options: ValidatePathOptions = {}, +): void { + const { allowRoot = false } = options; + + // Reject absolute paths + if (isAbsolute(filePath)) { + throw new PathValidationError( + "Absolute paths are not allowed", + "ABSOLUTE_PATH", + ); + } + + const normalized = normalize(filePath); + const segments = normalized.split(sep); + + // Reject ".." as a path segment (allows "..foo" directories) + if (segments.includes("..")) { + throw new PathValidationError( + "Path traversal not allowed", + "PATH_TRAVERSAL", + ); + } + + // Reject root path unless explicitly allowed + if (!allowRoot && (normalized === "" || normalized === ".")) { + throw new PathValidationError( + "Cannot target worktree root", + "INVALID_TARGET", + ); + } +} + +/** + * Validates and resolves a path within a worktree. Sync, simple. + * + * @param worktreePath - The worktree base path + * @param filePath - The relative file path to validate + * @param options - Validation options + * @returns The resolved full path + * @throws PathValidationError if path is invalid + */ +export function resolvePathInWorktree( + worktreePath: string, + filePath: string, + options: ValidatePathOptions = {}, +): string { + validateRelativePath(filePath, options); + // Use resolve to handle any worktreePath (relative or absolute) + return resolve(worktreePath, normalize(filePath)); +} + +/** + * Validates a path for git commands. Lighter check that allows root. + * + * @throws PathValidationError if path is invalid + */ +export function assertValidGitPath(filePath: string): void { + validateRelativePath(filePath, { allowRoot: true }); +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts new file mode 100644 index 000000000..9a931f0f8 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -0,0 +1,469 @@ +import type { Stats } from "node:fs"; +import { + lstat, + readFile, + readlink, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { + assertRegisteredWorktree, + PathValidationError, + resolvePathInWorktree, +} from "./path-validation"; + +/** + * Secure filesystem operations with built-in validation. + * + * Each operation: + * 1. Validates worktree is registered (security boundary) + * 2. Validates path doesn't escape worktree (defense in depth) + * 3. For writes: validates target is not a symlink escaping worktree + * 4. Performs the filesystem operation + * + * See path-validation.ts for the full security model and threat assumptions. + */ + +/** + * Check if a resolved path is within the worktree boundary using path.relative(). + * This is safer than string prefix matching which can have boundary bugs. + */ +function isPathWithinWorktree( + worktreeReal: string, + targetReal: string, +): boolean { + if (targetReal === worktreeReal) { + return true; + } + const relativePath = relative(worktreeReal, targetReal); + // Check if path escapes worktree: + // - ".." means direct parent + // - "../" prefix means ancestor escape (use sep for cross-platform) + // - Absolute path means completely outside + // Note: Don't use startsWith("..") as it incorrectly catches "..config" directories + // Note: Empty relativePath ("") case is already handled by the equality check above + const escapesWorktree = + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath); + + return !escapesWorktree; +} + +/** + * Validate that the parent directory chain stays within the worktree. + * Handles the case where the target file doesn't exist yet (ENOENT). + * + * This function walks up the directory tree to find the first existing + * ancestor and validates it. It also detects dangling symlinks by checking + * if any component is a symlink pointing outside the worktree. + * + * @throws PathValidationError if any ancestor escapes the worktree + */ +async function assertParentInWorktree( + worktreePath: string, + fullPath: string, +): Promise { + const worktreeReal = await realpath(worktreePath); + let currentPath = dirname(fullPath); + + // Walk up the directory tree until we find an existing directory + while (currentPath !== dirname(currentPath)) { + // Stop at filesystem root + try { + // First check if this path component is a symlink (even if target doesn't exist) + const stats = await lstat(currentPath); + + if (stats.isSymbolicLink()) { + // This is a symlink - validate its target even if it doesn't exist + const linkTarget = await readlink(currentPath); + // Resolve the link target relative to the symlink's parent + const resolvedTarget = isAbsolute(linkTarget) + ? linkTarget + : resolve(dirname(currentPath), linkTarget); + + // Try to get the realpath of the resolved target + try { + const targetReal = await realpath(resolvedTarget); + if (!isPathWithinWorktree(worktreeReal, targetReal)) { + throw new PathValidationError( + "Symlink in path resolves outside the worktree", + "SYMLINK_ESCAPE", + ); + } + } catch (error) { + // Target doesn't exist - check if the resolved target path + // would be within worktree if it existed + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + // For dangling symlinks, validate the target path itself + // We need to check if the target, when resolved, would be in worktree + // This is conservative: if we can't determine, fail closed + const targetRelative = relative(worktreeReal, resolvedTarget); + // Use sep-aware check to avoid false positives on "..config" dirs + if ( + targetRelative === ".." || + targetRelative.startsWith(`..${sep}`) || + isAbsolute(targetRelative) + ) { + throw new PathValidationError( + "Dangling symlink points outside the worktree", + "SYMLINK_ESCAPE", + ); + } + // Target would be within worktree if it existed - continue + return; + } + if (error instanceof PathValidationError) { + throw error; + } + // Other errors - fail closed for security + throw new PathValidationError( + "Cannot validate symlink target", + "SYMLINK_ESCAPE", + ); + } + return; // Symlink validated successfully + } + + // Not a symlink - get realpath and validate + const parentReal = await realpath(currentPath); + if (!isPathWithinWorktree(worktreeReal, parentReal)) { + throw new PathValidationError( + "Parent directory resolves outside the worktree", + "SYMLINK_ESCAPE", + ); + } + return; // Found valid ancestor + } catch (error) { + if (error instanceof PathValidationError) { + throw error; + } + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + // This ancestor doesn't exist either, keep walking up + currentPath = dirname(currentPath); + continue; + } + // Other errors (EACCES, ENOTDIR, etc.) - fail closed for security + throw new PathValidationError( + "Cannot validate path ancestry", + "SYMLINK_ESCAPE", + ); + } + } + + // Reached filesystem root without finding valid ancestor + throw new PathValidationError( + "Could not validate path ancestry within worktree", + "SYMLINK_ESCAPE", + ); +} + +/** + * Check if the resolved realpath stays within the worktree boundary. + * Prevents symlink escape attacks where a symlink points outside the worktree. + * + * @throws PathValidationError if realpath escapes worktree + */ +async function assertRealpathInWorktree( + worktreePath: string, + fullPath: string, +): Promise { + try { + const real = await realpath(fullPath); + const worktreeReal = await realpath(worktreePath); + + // Use path.relative for safer boundary checking + if (!isPathWithinWorktree(worktreeReal, real)) { + throw new PathValidationError( + "File is a symlink pointing outside the worktree", + "SYMLINK_ESCAPE", + ); + } + } catch (error) { + // If realpath fails with ENOENT, the target doesn't exist + // But the path itself might be a dangling symlink - check that first! + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + await assertDanglingSymlinkSafe(worktreePath, fullPath); + return; + } + // Re-throw PathValidationError + if (error instanceof PathValidationError) { + throw error; + } + // Other errors (permission denied, etc.) - fail closed for security + throw new PathValidationError( + "Cannot validate file path", + "SYMLINK_ESCAPE", + ); + } +} + +/** + * Handle the ENOENT case: check if fullPath is a dangling symlink pointing outside + * the worktree, or if it truly doesn't exist (in which case validate parent chain). + * + * Attack scenario this prevents: + * - Repo contains `docs/config.yml` → symlink to `~/.ssh/some_new_file` (doesn't exist) + * - realpath() fails with ENOENT (target missing) + * - Without this check, we'd only validate parent (`docs/`) which is valid + * - Write would follow symlink and create `~/.ssh/some_new_file` + * + * @throws PathValidationError if symlink escapes worktree + */ +async function assertDanglingSymlinkSafe( + worktreePath: string, + fullPath: string, +): Promise { + const worktreeReal = await realpath(worktreePath); + + try { + // Check if the path itself exists (as a symlink or otherwise) + const stats = await lstat(fullPath); + + if (stats.isSymbolicLink()) { + // It's a dangling symlink - validate where it points + const linkTarget = await readlink(fullPath); + const resolvedTarget = isAbsolute(linkTarget) + ? linkTarget + : resolve(dirname(fullPath), linkTarget); + + // Check if the resolved target would be within worktree + // For dangling symlinks, we can't use realpath on the target, + // so we check the literal resolved path + const targetRelative = relative(worktreeReal, resolvedTarget); + if ( + targetRelative === ".." || + targetRelative.startsWith(`..${sep}`) || + isAbsolute(targetRelative) + ) { + throw new PathValidationError( + "Dangling symlink points outside the worktree", + "SYMLINK_ESCAPE", + ); + } + // Dangling symlink points within worktree - allow the operation + return; + } + + // Not a symlink but lstat succeeded - weird state, but validate parent chain + await assertParentInWorktree(worktreePath, fullPath); + } catch (error) { + if (error instanceof PathValidationError) { + throw error; + } + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + // Path truly doesn't exist (not even as a symlink) - validate parent chain + await assertParentInWorktree(worktreePath, fullPath); + return; + } + // Other errors - fail closed + throw new PathValidationError("Cannot validate path", "SYMLINK_ESCAPE"); + } +} +export const secureFs = { + /** + * Read a file within a worktree. + * + * SECURITY: Enforces symlink-escape check. If the file is a symlink + * pointing outside the worktree, this will throw PathValidationError. + * + * @throws PathValidationError with code "SYMLINK_ESCAPE" if file escapes worktree + */ + async readFile( + worktreePath: string, + filePath: string, + encoding: BufferEncoding = "utf-8", + ): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Block reads through symlinks that escape the worktree + await assertRealpathInWorktree(worktreePath, fullPath); + + return readFile(fullPath, encoding); + }, + + /** + * Read a file as a Buffer within a worktree. + * + * SECURITY: Enforces symlink-escape check. If the file is a symlink + * pointing outside the worktree, this will throw PathValidationError. + * + * @throws PathValidationError with code "SYMLINK_ESCAPE" if file escapes worktree + */ + async readFileBuffer( + worktreePath: string, + filePath: string, + ): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Block reads through symlinks that escape the worktree + await assertRealpathInWorktree(worktreePath, fullPath); + + return readFile(fullPath); + }, + + /** + * Write content to a file within a worktree. + * + * SECURITY: Blocks writes if the file is a symlink pointing outside + * the worktree. This prevents malicious repos from tricking users + * into overwriting sensitive files like ~/.bashrc. + * + * @throws PathValidationError with code "SYMLINK_ESCAPE" if target escapes worktree + */ + async writeFile( + worktreePath: string, + filePath: string, + content: string, + ): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Block writes through symlinks that escape the worktree + await assertRealpathInWorktree(worktreePath, fullPath); + + await writeFile(fullPath, content, "utf-8"); + }, + + /** + * Delete a file or directory within a worktree. + * + * SECURITY: Validates the real path is within worktree before deletion. + * - Symlinks: Deletes the link itself (safe - link lives in worktree) + * - Files/dirs: Validates realpath then deletes + * + * This prevents symlink escape attacks where a malicious repo contains + * `docs -> /Users/victim` and a delete of `docs/file` would delete + * `/Users/victim/file`. + */ + async delete(worktreePath: string, filePath: string): Promise { + assertRegisteredWorktree(worktreePath); + // allowRoot: false prevents deleting the worktree itself + const fullPath = resolvePathInWorktree(worktreePath, filePath, { + allowRoot: false, + }); + + let stats: Stats; + try { + stats = await lstat(fullPath); + } catch (error) { + // File doesn't exist - idempotent delete, nothing to do + if ( + error instanceof Error && + "code" in error && + error.code === "ENOENT" + ) { + return; + } + throw error; + } + + if (stats.isSymbolicLink()) { + // Symlink - safe to delete the link itself (it lives in the worktree). + // Don't use recursive as we're just removing the symlink file. + await rm(fullPath); + return; + } + + // Regular file or directory - validate realpath is within worktree. + // This catches path traversal via symlinked parent components: + // e.g., `docs -> /victim`, delete `docs/file` → realpath is `/victim/file` + await assertRealpathInWorktree(worktreePath, fullPath); + + // Safe to delete - realpath confirmed within worktree. + // Note: Symlinks INSIDE a directory are safe - rm deletes the links, not targets. + await rm(fullPath, { recursive: true, force: true }); + }, + + /** + * Get file stats within a worktree. + * + * Uses `stat` (follows symlinks) to get the real file size. + * Validates that the resolved path stays within the worktree boundary. + */ + async stat(worktreePath: string, filePath: string): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + await assertRealpathInWorktree(worktreePath, fullPath); + return stat(fullPath); + }, + + /** + * Get file stats without following symlinks. + * + * Use this when you need to know if something IS a symlink. + * For size checks, prefer `stat` instead. + */ + async lstat(worktreePath: string, filePath: string): Promise { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + return lstat(fullPath); + }, + + /** + * Check if a file exists within a worktree. + * + * Returns false for non-existent files, symlink escapes, and validation failures. + */ + async exists(worktreePath: string, filePath: string): Promise { + try { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + await assertRealpathInWorktree(worktreePath, fullPath); + await stat(fullPath); + return true; + } catch { + return false; + } + }, + + /** + * Check if a file is a symlink that points outside the worktree. + * + * WARNING: This is a best-effort helper for UI warnings only. + * It returns `false` on errors, so it is NOT suitable as a security gate. + * For security enforcement, use the read/write methods which call + * assertRealpathInWorktree internally. + * + * @returns true if the file is definitely a symlink escaping the worktree, + * false if not escaping OR if we can't determine (errors) + */ + async isSymlinkEscaping( + worktreePath: string, + filePath: string, + ): Promise { + try { + assertRegisteredWorktree(worktreePath); + const fullPath = resolvePathInWorktree(worktreePath, filePath); + + // Check if it's a symlink first + const stats = await lstat(fullPath); + if (!stats.isSymbolicLink()) { + return false; + } + + // Check if realpath escapes worktree + const real = await realpath(fullPath); + const worktreeReal = await realpath(worktreePath); + + return !isPathWithinWorktree(worktreeReal, real); + } catch { + // If we can't determine, assume not escaping (file may not exist) + // NOTE: This makes this method unsuitable as a security gate + return false; + } + }, +}; diff --git a/apps/desktop/src/lib/trpc/routers/changes/staging.ts b/apps/desktop/src/lib/trpc/routers/changes/staging.ts index 1d3109a65..678e1304c 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/staging.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts @@ -1,8 +1,13 @@ -import { rm } from "node:fs/promises"; -import { join } from "node:path"; -import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { + gitCheckoutFile, + gitStageAll, + gitStageFile, + gitUnstageAll, + gitUnstageFile, + secureFs, +} from "./security"; export const createStagingRouter = () => { return router({ @@ -14,8 +19,7 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.add(input.filePath); + await gitStageFile(input.worktreePath, input.filePath); return { success: true }; }), @@ -27,8 +31,7 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.reset(["HEAD", "--", input.filePath]); + await gitUnstageFile(input.worktreePath, input.filePath); return { success: true }; }), @@ -40,24 +43,21 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.checkout(["--", input.filePath]); + await gitCheckoutFile(input.worktreePath, input.filePath); return { success: true }; }), stageAll: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.add("-A"); + await gitStageAll(input.worktreePath); return { success: true }; }), unstageAll: publicProcedure .input(z.object({ worktreePath: z.string() })) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const git = simpleGit(input.worktreePath); - await git.reset(["HEAD"]); + await gitUnstageAll(input.worktreePath); return { success: true }; }), @@ -69,8 +69,7 @@ export const createStagingRouter = () => { }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - const fullPath = join(input.worktreePath, input.filePath); - await rm(fullPath, { recursive: true, force: true }); + await secureFs.delete(input.worktreePath, input.filePath); return { success: true }; }), }); diff --git a/apps/desktop/src/lib/trpc/routers/changes/status.ts b/apps/desktop/src/lib/trpc/routers/changes/status.ts index c547b9855..2916e0609 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/status.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/status.ts @@ -1,9 +1,8 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import type { ChangedFile, GitChangesStatus } from "shared/changes-types"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; +import { assertRegisteredWorktree, secureFs } from "./security"; import { applyNumstatToFiles } from "./utils/apply-numstat"; import { parseGitLog, @@ -21,6 +20,8 @@ export const createStatusRouter = () => { }), ) .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const defaultBranch = input.defaultBranch || "main"; @@ -64,6 +65,8 @@ export const createStatusRouter = () => { }), ) .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + const git = simpleGit(input.worktreePath); const nameStatus = await git.raw([ @@ -141,18 +144,25 @@ async function getBranchComparison( return { commits, againstBase, ahead, behind }; } +/** Max file size for line counting (1 MiB) - skip larger files to avoid OOM */ +const MAX_LINE_COUNT_SIZE = 1 * 1024 * 1024; + async function applyUntrackedLineCount( worktreePath: string, untracked: ChangedFile[], ): Promise { for (const file of untracked) { try { - const fullPath = join(worktreePath, file.path); - const content = await readFile(fullPath, "utf-8"); + const stats = await secureFs.stat(worktreePath, file.path); + if (stats.size > MAX_LINE_COUNT_SIZE) continue; + + const content = await secureFs.readFile(worktreePath, file.path); const lineCount = content.split("\n").length; file.additions = lineCount; file.deletions = 0; - } catch {} + } catch { + // Skip files that fail validation or reading + } } } diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index ce6245816..8c2e5ed6e 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -1,6 +1,13 @@ -import { settings, type TerminalPreset } from "@superset/local-db"; +import { + settings, + TERMINAL_LINK_BEHAVIORS, + type TerminalPreset, +} from "@superset/local-db"; import { localDb } from "main/lib/local-db"; -import { DEFAULT_CONFIRM_ON_QUIT } from "shared/constants"; +import { + DEFAULT_CONFIRM_ON_QUIT, + DEFAULT_TERMINAL_LINK_BEHAVIOR, +} from "shared/constants"; import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -180,5 +187,25 @@ export const createSettingsRouter = () => { return { success: true }; }), + + getTerminalLinkBehavior: publicProcedure.query(() => { + const row = getSettings(); + return row.terminalLinkBehavior ?? DEFAULT_TERMINAL_LINK_BEHAVIOR; + }), + + setTerminalLinkBehavior: publicProcedure + .input(z.object({ behavior: z.enum(TERMINAL_LINK_BEHAVIORS) })) + .mutation(({ input }) => { + localDb + .insert(settings) + .values({ id: 1, terminalLinkBehavior: input.behavior }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalLinkBehavior: input.behavior }, + }) + .run(); + + return { success: true }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index 0d2b8e87f..afbff9fc9 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -10,19 +10,39 @@ import { import { z } from "zod"; import { publicProcedure, router } from "../.."; +/** + * Zod schema for FileViewerState persistence. + * Note: initialLine/initialColumn from shared/tabs-types.ts are intentionally + * omitted as they are transient (applied once on open, not persisted). + */ +const fileViewerStateSchema = z.object({ + filePath: z.string(), + viewMode: z.enum(["rendered", "raw", "diff"]), + isLocked: z.boolean(), + diffLayout: z.enum(["inline", "side-by-side"]), + diffCategory: z + .enum(["against-base", "committed", "staged", "unstaged"]) + .optional(), + commitHash: z.string().optional(), + oldPath: z.string().optional(), +}); + /** * Zod schema for Pane */ const paneSchema = z.object({ id: z.string(), tabId: z.string(), - type: z.enum(["terminal", "webview"]), + type: z.enum(["terminal", "webview", "file-viewer"]), name: z.string(), isNew: z.boolean().optional(), needsAttention: z.boolean().optional(), initialCommands: z.array(z.string()).optional(), initialCwd: z.string().optional(), url: z.string().optional(), + cwd: z.string().nullable().optional(), + cwdConfirmed: z.boolean().optional(), + fileViewer: fileViewerStateSchema.optional(), }); /** diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index c2b41c99d..278072e61 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -8,7 +8,7 @@ import { worktrees, } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; -import { and, desc, eq, isNotNull } from "drizzle-orm"; +import { and, desc, eq, isNotNull, not } from "drizzle-orm"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db"; import { terminalManager } from "main/lib/terminal"; @@ -561,22 +561,11 @@ export const createWorkspacesRouter = () => { }; } - // Shift existing workspaces to make room at front - const projectWorkspaces = localDb - .select() - .from(workspaces) - .where(eq(workspaces.projectId, input.projectId)) - .all(); - for (const ws of projectWorkspaces) { - localDb - .update(workspaces) - .set({ tabOrder: ws.tabOrder + 1 }) - .where(eq(workspaces.id, ws.id)) - .run(); - } - - // Insert new workspace - const workspace = localDb + // Insert new workspace first with conflict handling for race conditions + // The unique partial index (projectId WHERE type='branch') prevents duplicates + // We insert first, then shift - this prevents race conditions where + // concurrent calls both shift before either inserts (causing double shifts) + const insertResult = localDb .insert(workspaces) .values({ projectId: input.projectId, @@ -585,8 +574,54 @@ export const createWorkspacesRouter = () => { name: branch, tabOrder: 0, }) + .onConflictDoNothing() .returning() - .get(); + .all(); + + const wasExisting = insertResult.length === 0; + + // Only shift existing workspaces if we successfully inserted + // Losers of the race should NOT shift (they didn't create anything) + if (!wasExisting) { + const newWorkspaceId = insertResult[0].id; + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, input.projectId), + // Exclude the workspace we just inserted + not(eq(workspaces.id, newWorkspaceId)), + ), + ) + .all(); + for (const ws of projectWorkspaces) { + localDb + .update(workspaces) + .set({ tabOrder: ws.tabOrder + 1 }) + .where(eq(workspaces.id, ws.id)) + .run(); + } + } + + // If insert returned nothing, another concurrent call won the race + // Fetch the existing workspace instead + const workspace = + insertResult[0] ?? + localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, input.projectId), + eq(workspaces.type, "branch"), + ), + ) + .get(); + + if (!workspace) { + throw new Error("Failed to create or find branch workspace"); + } // Update settings localDb @@ -598,41 +633,43 @@ export const createWorkspacesRouter = () => { }) .run(); - // Update project - const activeProjects = localDb - .select() - .from(projects) - .where(isNotNull(projects.tabOrder)) - .all(); - const maxProjectTabOrder = - activeProjects.length > 0 - ? Math.max(...activeProjects.map((p) => p.tabOrder ?? 0)) - : -1; + // Update project (only if we actually inserted a new workspace) + if (!wasExisting) { + const activeProjects = localDb + .select() + .from(projects) + .where(isNotNull(projects.tabOrder)) + .all(); + const maxProjectTabOrder = + activeProjects.length > 0 + ? Math.max(...activeProjects.map((p) => p.tabOrder ?? 0)) + : -1; - localDb - .update(projects) - .set({ - lastOpenedAt: Date.now(), - tabOrder: - project.tabOrder === null - ? maxProjectTabOrder + 1 - : project.tabOrder, - }) - .where(eq(projects.id, input.projectId)) - .run(); + localDb + .update(projects) + .set({ + lastOpenedAt: Date.now(), + tabOrder: + project.tabOrder === null + ? maxProjectTabOrder + 1 + : project.tabOrder, + }) + .where(eq(projects.id, input.projectId)) + .run(); - track("workspace_opened", { - workspace_id: workspace.id, - project_id: project.id, - type: "branch", - was_existing: false, - }); + track("workspace_opened", { + workspace_id: workspace.id, + project_id: project.id, + type: "branch", + was_existing: false, + }); + } return { workspace, worktreePath: project.mainRepoPath, projectId: project.id, - wasExisting: false, + wasExisting, }; }), @@ -802,6 +839,7 @@ export const createWorkspacesRouter = () => { createdAt: number; updatedAt: number; lastOpenedAt: number; + isUnread: boolean; }>; } >(); @@ -831,6 +869,7 @@ export const createWorkspacesRouter = () => { ...workspace, type: workspace.type as "worktree" | "branch", worktreePath: getWorkspacePath(workspace) ?? "", + isUnread: workspace.isUnread ?? false, }); } } @@ -1257,10 +1296,18 @@ export const createWorkspacesRouter = () => { throw new Error(`Workspace ${input.id} not found`); } + // Track if workspace was unread before clearing + const wasUnread = workspace.isUnread ?? false; + const now = Date.now(); localDb .update(workspaces) - .set({ lastOpenedAt: now, updatedAt: now }) + .set({ + lastOpenedAt: now, + updatedAt: now, + // Auto-clear unread state when switching to workspace + isUnread: false, + }) .where(eq(workspaces.id, input.id)) .run(); @@ -1273,7 +1320,7 @@ export const createWorkspacesRouter = () => { }) .run(); - return { success: true }; + return { success: true, wasUnread }; }), reorder: publicProcedure @@ -1611,6 +1658,27 @@ export const createWorkspacesRouter = () => { }; }), + setUnread: publicProcedure + .input(z.object({ id: z.string(), isUnread: z.boolean() })) + .mutation(({ input }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.id)) + .get(); + if (!workspace) { + throw new Error(`Workspace ${input.id} not found`); + } + + localDb + .update(workspaces) + .set({ isUnread: input.isUnread }) + .where(eq(workspaces.id, input.id)) + .run(); + + return { success: true, isUnread: input.isUnread }; + }), + close: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx new file mode 100644 index 000000000..91295eaa9 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/SafeImage.tsx @@ -0,0 +1,71 @@ +import { LuImageOff } from "react-icons/lu"; + +/** + * Check if an image source is safe to load. + * + * Uses strict ALLOWLIST approach - only data: URLs are safe. + * + * ALLOWED: + * - data: URLs (embedded base64 images) + * + * BLOCKED (everything else): + * - http://, https:// (tracking pixels, privacy leak) + * - file:// URLs (arbitrary local file access) + * - Absolute paths /... or \... (become file:// in Electron) + * - Relative paths with .. (can escape repo boundary) + * - UNC paths //server/share (Windows NTLM credential leak) + * - Empty or malformed sources + * + * Security context: In Electron production, renderer loads via file:// + * protocol. Any non-data: image src could access local filesystem or + * trigger network requests to attacker-controlled servers. + */ +function isSafeImageSrc(src: string | undefined): boolean { + if (!src) return false; + const trimmed = src.trim(); + if (trimmed.length === 0) return false; + + // Only allow data: URLs (embedded images) + // These are self-contained and can't access external resources + return trimmed.toLowerCase().startsWith("data:"); +} + +interface SafeImageProps { + src?: string; + alt?: string; + className?: string; +} + +/** + * Safe image component for untrusted markdown content. + * + * Only renders embedded data: URLs. All other sources are blocked + * to prevent local file access, network requests, and path traversal + * attacks from malicious repository content. + * + * Future: Could add opt-in support for repo-relative images via a + * secure loader that validates paths through secureFs and serves + * as blob: URLs. + */ +export function SafeImage({ src, alt, className }: SafeImageProps) { + if (!isSafeImageSrc(src)) { + return ( +
+ + Image blocked +
+ ); + } + + // Safe to render - embedded data: URL + return ( + {alt} + ); +} diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts new file mode 100644 index 000000000..3a608bf50 --- /dev/null +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/SafeImage/index.ts @@ -0,0 +1 @@ +export { SafeImage } from "./SafeImage"; diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts b/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts index b5cd6b0e8..d20884501 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/components/index.ts @@ -1,2 +1,3 @@ export { CodeBlock } from "./CodeBlock"; +export { SafeImage } from "./SafeImage"; export { SelectionContextMenu } from "./SelectionContextMenu"; diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx index 61ef8cdd6..c19cc5e1b 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/default/config.tsx @@ -1,5 +1,5 @@ import { cn } from "@superset/ui/utils"; -import { CodeBlock } from "../../components"; +import { CodeBlock, SafeImage } from "../../components"; import type { MarkdownStyleConfig } from "../types"; import "./default.css"; @@ -41,7 +41,11 @@ export const defaultConfig: MarkdownStyleConfig = { ), img: ({ src, alt }) => ( - {alt} + ), hr: () =>
, li: ({ children, className }) => { diff --git a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx index 077f8d913..5173f7e45 100644 --- a/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx +++ b/apps/desktop/src/renderer/components/MarkdownRenderer/styles/tufte/config.tsx @@ -1,4 +1,4 @@ -import { CodeBlock } from "../../components"; +import { CodeBlock, SafeImage } from "../../components"; import type { MarkdownStyleConfig } from "../types"; import "./tufte.css"; @@ -12,5 +12,7 @@ export const tufteConfig: MarkdownStyleConfig = { {children} ), + // Block external images for privacy (tracking pixels, etc.) + img: ({ src, alt }) => , }, }; diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx index f0fa3815d..768d2b2a2 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/NewWorkspaceModal.tsx @@ -36,6 +36,7 @@ import { useCreateWorkspace } from "renderer/react-query/workspaces"; import { useCloseNewWorkspaceModal, useNewWorkspaceModalOpen, + usePreSelectedProjectId, } from "renderer/stores/new-workspace-modal"; import { ExistingWorktreesList } from "./components/ExistingWorktreesList"; @@ -57,6 +58,7 @@ type Mode = "existing" | "new"; export function NewWorkspaceModal() { const isOpen = useNewWorkspaceModalOpen(); const closeModal = useCloseNewWorkspaceModal(); + const preSelectedProjectId = usePreSelectedProjectId(); const [selectedProjectId, setSelectedProjectId] = useState( null, ); @@ -94,12 +96,15 @@ export function NewWorkspaceModal() { ); }, [branchData?.branches, branchSearch]); - // Auto-select current project when modal opens + // Auto-select project when modal opens (prioritize pre-selected, then current) useEffect(() => { - if (isOpen && currentProjectId && !selectedProjectId) { - setSelectedProjectId(currentProjectId); + if (isOpen && !selectedProjectId) { + const projectToSelect = preSelectedProjectId ?? currentProjectId; + if (projectToSelect) { + setSelectedProjectId(projectToSelect); + } } - }, [isOpen, currentProjectId, selectedProjectId]); + }, [isOpen, currentProjectId, selectedProjectId, preSelectedProjectId]); // Effective base branch - use explicit selection or fall back to default const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? null; diff --git a/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts new file mode 100644 index 000000000..acd914cb1 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useWorkspaceShortcuts.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { trpc } from "renderer/lib/trpc"; +import { + useCreateBranchWorkspace, + useSetActiveWorkspace, +} from "renderer/react-query/workspaces"; +import { useAppHotkey } from "renderer/stores/hotkeys"; + +/** + * Shared hook for workspace keyboard shortcuts and auto-creation logic. + * Used by WorkspaceSidebar for navigation between workspaces. + * + * 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); + + const switchToWorkspace = useCallback( + (index: number) => { + const workspace = allWorkspaces[index]; + if (workspace) { + setActiveWorkspace.mutate({ id: workspace.id }); + } + }, + [allWorkspaces, setActiveWorkspace], + ); + + useAppHotkey("JUMP_TO_WORKSPACE_1", () => switchToWorkspace(0), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_2", () => switchToWorkspace(1), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_3", () => switchToWorkspace(2), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_4", () => switchToWorkspace(3), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_5", () => switchToWorkspace(4), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_6", () => switchToWorkspace(5), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_7", () => switchToWorkspace(6), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_8", () => switchToWorkspace(7), undefined, [ + switchToWorkspace, + ]); + useAppHotkey("JUMP_TO_WORKSPACE_9", () => switchToWorkspace(8), undefined, [ + switchToWorkspace, + ]); + + useAppHotkey( + "PREV_WORKSPACE", + () => { + if (!activeWorkspaceId) return; + const currentIndex = allWorkspaces.findIndex( + (w) => w.id === activeWorkspaceId, + ); + if (currentIndex > 0) { + setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex - 1].id }); + } + }, + undefined, + [activeWorkspaceId, allWorkspaces, setActiveWorkspace], + ); + + useAppHotkey( + "NEXT_WORKSPACE", + () => { + if (!activeWorkspaceId) return; + const currentIndex = allWorkspaces.findIndex( + (w) => w.id === activeWorkspaceId, + ); + if (currentIndex < allWorkspaces.length - 1) { + setActiveWorkspace.mutate({ id: allWorkspaces[currentIndex + 1].id }); + } + }, + undefined, + [activeWorkspaceId, allWorkspaces, setActiveWorkspace], + ); + + return { + groups, + allWorkspaces, + activeWorkspaceId, + setActiveWorkspace, + }; +} diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts index 60a9c29b7..438ba8495 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/index.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -6,3 +6,4 @@ export { useOpenWorktree } from "./useOpenWorktree"; export { useReorderWorkspaces } from "./useReorderWorkspaces"; export { useSetActiveWorkspace } from "./useSetActiveWorkspace"; export { useUpdateWorkspace } from "./useUpdateWorkspace"; +export { useWorkspaceDeleteHandler } from "./useWorkspaceDeleteHandler"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts index 7dc43e36b..debb3a08b 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts @@ -1,23 +1,62 @@ +import { toast } from "@superset/ui/sonner"; import { trpc } from "renderer/lib/trpc"; /** * Mutation hook for setting the active workspace * Automatically invalidates getActive and getAll queries on success + * Shows undo toast if workspace was marked as unread (auto-cleared on switch) */ export function useSetActiveWorkspace( options?: Parameters[0], ) { const utils = trpc.useUtils(); + const setUnread = trpc.workspaces.setUnread.useMutation({ + onSuccess: () => { + utils.workspaces.getAllGrouped.invalidate(); + }, + onError: (error) => { + console.error("[workspace/setUnread] Failed to update unread status:", { + error: error.message, + }); + toast.error(`Failed to undo: ${error.message}`); + }, + }); return trpc.workspaces.setActive.useMutation({ ...options, - onSuccess: async (...args) => { + onError: (error, variables, context, meta) => { + console.error("[workspace/setActive] Failed to set active workspace:", { + workspaceId: variables.id, + error: error.message, + }); + toast.error(`Failed to switch workspace: ${error.message}`); + options?.onError?.(error, variables, context, meta); + }, + onSuccess: async (data, variables, ...rest) => { // Auto-invalidate active workspace and all workspaces queries - await utils.workspaces.getActive.invalidate(); - await utils.workspaces.getAll.invalidate(); + await Promise.all([ + utils.workspaces.getActive.invalidate(), + utils.workspaces.getAll.invalidate(), + utils.workspaces.getAllGrouped.invalidate(), + ]); + + // Show undo toast if workspace was marked as unread + if (data.wasUnread) { + toast("Marked as read", { + description: "Workspace unread marker cleared", + action: { + label: "Undo", + onClick: () => { + setUnread.mutate({ id: variables.id, isUnread: true }); + }, + }, + duration: 5000, + }); + } // Call user's onSuccess if provided - await options?.onSuccess?.(...args); + // biome-ignore lint/suspicious/noExplicitAny: spread args for compatibility + await (options?.onSuccess as any)?.(data, variables, ...rest); }, }); } diff --git a/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts new file mode 100644 index 000000000..cdd2075e1 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useWorkspaceDeleteHandler.ts @@ -0,0 +1,29 @@ +import { useState } from "react"; + +interface UseWorkspaceDeleteHandlerResult { + /** Whether the delete dialog should be shown */ + showDeleteDialog: boolean; + /** Set whether the delete dialog should be shown */ + setShowDeleteDialog: (show: boolean) => void; + /** Handle delete click - always shows the dialog to let user choose close or delete */ + handleDeleteClick: (e?: React.MouseEvent) => void; +} + +/** + * Shared hook for workspace delete/close dialog state. + * Always shows the confirmation dialog to let user choose between closing or deleting. + */ +export function useWorkspaceDeleteHandler(): UseWorkspaceDeleteHandlerResult { + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + + const handleDeleteClick = (e?: React.MouseEvent) => { + e?.stopPropagation(); + setShowDeleteDialog(true); + }; + + return { + showDeleteDialog, + setShowDeleteDialog, + handleDeleteClick, + }; +} 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 f21aff0a3..7bbac096c 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/BehaviorSettings.tsx @@ -1,37 +1,73 @@ +import type { TerminalLinkBehavior } from "@superset/local-db"; import { Label } from "@superset/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@superset/ui/select"; import { Switch } from "@superset/ui/switch"; import { trpc } from "renderer/lib/trpc"; 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) => { + const handleConfirmToggle = (enabled: boolean) => { setConfirmOnQuit.mutate({ enabled }); }; + // Terminal link behavior setting + const { data: terminalLinkBehavior, isLoading: isLoadingLinkBehavior } = + trpc.settings.getTerminalLinkBehavior.useQuery(); + + const setTerminalLinkBehavior = + trpc.settings.setTerminalLinkBehavior.useMutation({ + onMutate: async ({ behavior }) => { + await utils.settings.getTerminalLinkBehavior.cancel(); + const previous = utils.settings.getTerminalLinkBehavior.getData(); + utils.settings.getTerminalLinkBehavior.setData(undefined, behavior); + return { previous }; + }, + onError: (_err, _vars, context) => { + if (context?.previous !== undefined) { + utils.settings.getTerminalLinkBehavior.setData( + undefined, + context.previous, + ); + } + }, + onSettled: () => { + utils.settings.getTerminalLinkBehavior.invalidate(); + }, + }); + + const handleLinkBehaviorChange = (value: string) => { + setTerminalLinkBehavior.mutate({ + behavior: value as TerminalLinkBehavior, + }); + }; + return (
@@ -42,6 +78,7 @@ export function BehaviorSettings() {
+ {/* Confirm on Quit */}
+ +
+
+ +

+ Choose how to open file paths when Cmd+clicking in the terminal +

+
+ +
); diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx b/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx index 1a3a7ac95..74285a1a9 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/WorkspaceSettings/WorkspaceSettings.tsx @@ -2,7 +2,7 @@ import { Input } from "@superset/ui/input"; import { HiOutlineFolder, HiOutlinePencilSquare } from "react-icons/hi2"; import { LuGitBranch } from "react-icons/lu"; import { trpc } from "renderer/lib/trpc"; -import { useWorkspaceRename } from "renderer/screens/main/components/TopBar/WorkspaceTabs/useWorkspaceRename"; +import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; export function WorkspaceSettings() { const { data: activeWorkspace, isLoading } = diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx similarity index 87% rename from apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx rename to apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx index d2337d7ba..35b2cd353 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/SidebarControl.tsx +++ b/apps/desktop/src/renderer/screens/main/components/SidebarControl/SidebarControl.tsx @@ -14,7 +14,9 @@ export function SidebarControl() { variant="ghost" size="icon" onClick={toggleSidebar} - aria-label={isSidebarOpen ? "Hide sidebar" : "Show sidebar"} + aria-label={ + isSidebarOpen ? "Hide Changes Sidebar" : "Show Changes Sidebar" + } className="no-drag" > {isSidebarOpen ? ( @@ -26,7 +28,7 @@ export function SidebarControl() { diff --git a/apps/desktop/src/renderer/screens/main/components/SidebarControl/index.ts b/apps/desktop/src/renderer/screens/main/components/SidebarControl/index.ts new file mode 100644 index 000000000..c4a177ae7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/SidebarControl/index.ts @@ -0,0 +1 @@ +export { SidebarControl } from "./SidebarControl"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/OpenInMenuButton.tsx similarity index 66% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx rename to apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/OpenInMenuButton.tsx index 222cade48..5287546fb 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/WorkspaceActionBarRight.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/OpenInMenuButton.tsx @@ -10,9 +10,11 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; import { HiChevronDown } from "react-icons/hi2"; -import { LuArrowUpRight, LuCopy } from "react-icons/lu"; +import { LuCopy } from "react-icons/lu"; import jetbrainsIcon from "renderer/assets/app-icons/jetbrains.svg"; import vscodeIcon from "renderer/assets/app-icons/vscode.svg"; import { @@ -21,117 +23,105 @@ import { JETBRAINS_OPTIONS, VSCODE_OPTIONS, } from "renderer/components/OpenInButton"; -import { shortenHomePath } from "renderer/lib/formatPath"; import { trpc } from "renderer/lib/trpc"; import { useHotkeyText } from "renderer/stores/hotkeys"; -interface FormattedPath { - prefix: string; - worktreeName: string; -} - -function formatWorktreePath( - path: string, - homeDir: string | undefined, -): FormattedPath { - const shortenedPath = shortenHomePath(path, homeDir); - - // Split into prefix and worktree name (last segment) - const lastSlashIndex = shortenedPath.lastIndexOf("/"); - if (lastSlashIndex !== -1) { - return { - prefix: shortenedPath.slice(0, lastSlashIndex + 1), - worktreeName: shortenedPath.slice(lastSlashIndex + 1), - }; - } - - return { prefix: "", worktreeName: shortenedPath }; -} - -interface WorkspaceActionBarRightProps { +interface OpenInMenuButtonProps { worktreePath: string; } -export function WorkspaceActionBarRight({ - worktreePath, -}: WorkspaceActionBarRightProps) { - const { data: homeDir } = trpc.window.getHomeDir.useQuery(); +export function OpenInMenuButton({ worktreePath }: OpenInMenuButtonProps) { const utils = trpc.useUtils(); const { data: lastUsedApp = "cursor" } = trpc.settings.getLastUsedApp.useQuery(); const openInApp = trpc.external.openInApp.useMutation({ onSuccess: () => utils.settings.getLastUsedApp.invalidate(), + onError: (error) => toast.error(`Failed to open: ${error.message}`), + }); + const copyPath = trpc.external.copyPath.useMutation({ + onSuccess: () => toast.success("Path copied to clipboard"), + onError: (error) => toast.error(`Failed to copy path: ${error.message}`), }); - const copyPath = trpc.external.copyPath.useMutation(); - const formattedPath = formatWorktreePath(worktreePath, homeDir); const currentApp = getAppOption(lastUsedApp); const openInShortcut = useHotkeyText("OPEN_IN_APP"); const copyPathShortcut = useHotkeyText("COPY_PATH"); const showOpenInShortcut = openInShortcut !== "Unassigned"; const showCopyPathShortcut = copyPathShortcut !== "Unassigned"; + const isLoading = openInApp.isPending || copyPath.isPending; const handleOpenInEditor = () => { + if (isLoading) return; openInApp.mutate({ path: worktreePath, app: lastUsedApp }); }; const handleOpenInOtherApp = (appId: ExternalApp) => { + if (isLoading) return; openInApp.mutate({ path: worktreePath, app: appId }); }; const handleCopyPath = () => { + if (isLoading) return; copyPath.mutate(worktreePath); }; const BUTTON_HEIGHT = 24; return ( - <> - {/* Path - clickable to open */} +
+ {/* Main button - opens in last used app */} - - - Open in {currentApp.displayLabel ?? currentApp.label} - - {showOpenInShortcut ? openInShortcut : "—"} - - + +
+ + Open in {currentApp.displayLabel ?? currentApp.label} + {showOpenInShortcut && ( + + {openInShortcut} + + )} + + + {worktreePath} + +
- {/* Open dropdown button */} + {/* Dropdown trigger */} @@ -215,6 +205,6 @@ export function WorkspaceActionBarRight({ - +
); } diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/ViewModeToggleCompact.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/ViewModeToggleCompact.tsx new file mode 100644 index 000000000..a93f91fb3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/ViewModeToggleCompact.tsx @@ -0,0 +1,61 @@ +import { cn } from "@superset/ui/utils"; +import { + useWorkspaceViewModeStore, + type WorkspaceViewMode, +} from "renderer/stores/workspace-view-mode"; + +interface ViewModeToggleCompactProps { + workspaceId: string; +} + +export function ViewModeToggleCompact({ + workspaceId, +}: ViewModeToggleCompactProps) { + // Select only this workspace's mode to minimize rerenders + const currentMode = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId[workspaceId] ?? "workbench", + ); + const setWorkspaceViewMode = useWorkspaceViewModeStore( + (s) => s.setWorkspaceViewMode, + ); + + const handleModeChange = (mode: WorkspaceViewMode) => { + setWorkspaceViewMode(workspaceId, mode); + }; + + const BUTTON_HEIGHT = 24; + + return ( +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/WorkspaceControls.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/WorkspaceControls.tsx new file mode 100644 index 000000000..e59005195 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/WorkspaceControls.tsx @@ -0,0 +1,22 @@ +import { OpenInMenuButton } from "./OpenInMenuButton"; +import { ViewModeToggleCompact } from "./ViewModeToggleCompact"; + +interface WorkspaceControlsProps { + workspaceId: string | undefined; + worktreePath: string | undefined; +} + +export function WorkspaceControls({ + workspaceId, + worktreePath, +}: WorkspaceControlsProps) { + // Don't render if no active workspace with a worktree path + if (!workspaceId || !worktreePath) return null; + + return ( +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/index.ts b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/index.ts new file mode 100644 index 000000000..736202ad1 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceControls/index.ts @@ -0,0 +1 @@ +export { WorkspaceControls } from "./WorkspaceControls"; 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 000000000..a5a517901 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceSidebarControl.tsx @@ -0,0 +1,47 @@ +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 { + formatHotkeyDisplay, + getCurrentPlatform, + getHotkey, +} from "shared/hotkeys"; + +export function WorkspaceSidebarControl() { + const { isOpen, toggleOpen } = useWorkspaceSidebarStore(); + + return ( + + + + + + + Toggle Workspaces + + {formatHotkeyDisplay( + getHotkey("TOGGLE_WORKSPACE_SIDEBAR"), + getCurrentPlatform(), + ).map((key) => ( + {key} + ))} + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx deleted file mode 100644 index b63425907..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/CreateWorkspaceButton.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; -import { toast } from "@superset/ui/sonner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { useCallback, useState } from "react"; -import { - HiChevronDown, - HiFolderOpen, - HiMiniPlus, - HiOutlineBolt, -} from "react-icons/hi2"; -import { trpc } from "renderer/lib/trpc"; -import { useOpenNew } from "renderer/react-query/projects"; -import { - useCreateBranchWorkspace, - useCreateWorkspace, -} from "renderer/react-query/workspaces"; -import { useAppHotkey, useHotkeyText } from "renderer/stores/hotkeys"; -import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; -import { InitGitDialog } from "../../StartView/InitGitDialog"; - -export interface CreateWorkspaceButtonProps { - className?: string; -} - -export function CreateWorkspaceButton({ - className, -}: CreateWorkspaceButtonProps) { - const [open, setOpen] = useState(false); - const [initGitDialog, setInitGitDialog] = useState<{ - isOpen: boolean; - selectedPath: string; - }>({ isOpen: false, selectedPath: "" }); - - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const { data: recentProjects = [] } = trpc.projects.getRecents.useQuery(); - const createWorkspace = useCreateWorkspace(); - const createBranchWorkspace = useCreateBranchWorkspace(); - const openNew = useOpenNew(); - const openModal = useOpenNewWorkspaceModal(); - - const currentProject = recentProjects.find( - (p) => p.id === activeWorkspace?.projectId, - ); - - const isLoading = - createWorkspace.isPending || - createBranchWorkspace.isPending || - openNew.isPending; - - const handleModalCreate = useCallback(() => { - setOpen(false); - openModal(); - }, [openModal]); - - const handleOpenNewProject = useCallback(async () => { - setOpen(false); - 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) { - setInitGitDialog({ - isOpen: true, - selectedPath: result.selectedPath, - }); - 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", - }); - } - }, [openNew, createBranchWorkspace]); - - const handleQuickCreate = useCallback(() => { - setOpen(false); - if (currentProject) { - toast.promise( - createWorkspace.mutateAsync({ projectId: currentProject.id }), - { - loading: "Creating workspace...", - success: "Workspace created", - error: (err) => - err instanceof Error ? err.message : "Failed to create workspace", - }, - ); - } else { - handleOpenNewProject(); - } - }, [currentProject, createWorkspace, handleOpenNewProject]); - - // Keyboard shortcuts - const handleQuickCreateHotkey = useCallback(() => { - if (!isLoading) handleQuickCreate(); - }, [isLoading, handleQuickCreate]); - - const handleOpenProjectHotkey = useCallback(() => { - if (!isLoading) handleOpenNewProject(); - }, [isLoading, handleOpenNewProject]); - - useAppHotkey("NEW_WORKSPACE", handleModalCreate, undefined, [ - handleModalCreate, - ]); - useAppHotkey("QUICK_CREATE_WORKSPACE", handleQuickCreateHotkey, undefined, [ - handleQuickCreateHotkey, - ]); - useAppHotkey("OPEN_PROJECT", handleOpenProjectHotkey, undefined, [ - handleOpenProjectHotkey, - ]); - - const newWorkspaceShortcut = useHotkeyText("NEW_WORKSPACE"); - const quickCreateShortcut = useHotkeyText("QUICK_CREATE_WORKSPACE"); - const openProjectShortcut = useHotkeyText("OPEN_PROJECT"); - const showNewWorkspaceShortcut = newWorkspaceShortcut !== "Unassigned"; - const showQuickCreateShortcut = quickCreateShortcut !== "Unassigned"; - const showOpenProjectShortcut = openProjectShortcut !== "Unassigned"; - - return ( -
- - - - - - New workspace - - - - - - - - - - - - More options - - - - - - New Workspace - {showNewWorkspaceShortcut && ( - - {newWorkspaceShortcut} - - )} - - - - Quick Create - {showQuickCreateShortcut && ( - - {quickCreateShortcut} - - )} - - - - - Open Project - {showOpenProjectShortcut && ( - - {openProjectShortcut} - - )} - - - - - setInitGitDialog({ isOpen: false, selectedPath: "" })} - onError={(error) => - toast.error("Failed to initialize git", { description: error }) - } - /> -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx deleted file mode 100644 index 1b567dddd..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/SettingsTab.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { cn } from "@superset/ui/utils"; -import { HiMiniXMark, HiOutlineCog6Tooth } from "react-icons/hi2"; -import { - useCloseSettingsTab, - useOpenSettings, -} from "renderer/stores/app-state"; - -interface SettingsTabProps { - width: number; - isActive: boolean; -} - -export function SettingsTab({ width, isActive }: SettingsTabProps) { - const openSettings = useOpenSettings(); - const closeSettingsTab = useCloseSettingsTab(); - - return ( -
- - - -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx deleted file mode 100644 index 0b042f2ef..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { AnimatePresence, motion } from "framer-motion"; -import { useState } from "react"; -import { WorkspaceGroupHeader } from "./WorkspaceGroupHeader"; -import { WorkspaceItem } from "./WorkspaceItem"; - -interface Workspace { - id: string; - projectId: string; - worktreePath: string; - type: "worktree" | "branch"; - branch: string; - name: string; - tabOrder: number; -} - -interface WorkspaceGroupProps { - projectId: string; - projectName: string; - projectColor: string; - projectIndex: number; - workspaces: Workspace[]; - activeWorkspaceId: string | null; - workspaceWidth: number; - hoveredWorkspaceId: string | null; - onWorkspaceHover: (id: string | null) => void; -} - -export function WorkspaceGroup({ - projectId, - projectName, - projectColor, - projectIndex, - workspaces, - activeWorkspaceId, - workspaceWidth, - hoveredWorkspaceId: _hoveredWorkspaceId, - onWorkspaceHover, -}: WorkspaceGroupProps) { - const [isCollapsed, setIsCollapsed] = useState(false); - - return ( -
- {/* Project group badge */} - setIsCollapsed(!isCollapsed)} - /> - - {/* Workspaces with colored line (collapsed shows only active tab) */} -
- - {(isCollapsed - ? workspaces.filter((w) => w.id === activeWorkspaceId) - : workspaces - ).map((workspace, index) => ( - - onWorkspaceHover(workspace.id)} - onMouseLeave={() => onWorkspaceHover(null)} - /> - - ))} - -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx deleted file mode 100644 index 15f87c6e9..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupContextMenu.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import type { KeyboardEvent, ReactNode } from "react"; -import { useEffect, useRef, useState } from "react"; -import { - useCloseProject, - useUpdateProject, -} from "renderer/react-query/projects"; -import { PROJECT_COLORS } from "shared/constants/project-colors"; - -interface WorkspaceGroupContextMenuProps { - projectId: string; - projectName: string; - projectColor: string; - children: ReactNode; -} - -export function WorkspaceGroupContextMenu({ - projectId, - projectName, - projectColor, - children, -}: WorkspaceGroupContextMenuProps) { - const [name, setName] = useState(projectName); - const inputRef = useRef(null); - const skipBlurSubmit = useRef(false); - const updateProject = useUpdateProject(); - const closeProject = useCloseProject(); - - useEffect(() => { - setName(projectName); - }, [projectName]); - - const handleOpenChange = (open: boolean) => { - if (open) { - // Small delay to ensure the menu is fully rendered - setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, 0); - } - }; - - const submitName = () => { - const trimmed = name.trim(); - - if (!trimmed) { - setName(projectName); - return; - } - - if (trimmed !== name) { - setName(trimmed); - } - - if (trimmed !== projectName) { - updateProject.mutate({ - id: projectId, - patch: { name: trimmed }, - }); - } - }; - - const handleNameKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - skipBlurSubmit.current = true; - submitName(); - inputRef.current?.blur(); - } else if (event.key === "Escape") { - event.preventDefault(); - setName(projectName); - skipBlurSubmit.current = true; - inputRef.current?.blur(); - } - }; - - const handleBlur = () => { - if (skipBlurSubmit.current) { - skipBlurSubmit.current = false; - return; - } - - submitName(); - }; - - const handleColorChange = (color: string) => { - if (color === projectColor) { - return; - } - - updateProject.mutate({ - id: projectId, - patch: { color }, - }); - }; - - return ( - - {children} - -
event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > -

Workspace group name

- setName(event.target.value)} - onBlur={handleBlur} - onKeyDown={handleNameKeyDown} - className="w-full rounded-md border border-border bg-muted/50 px-2 py-1 text-sm text-foreground outline-none focus:border-primary focus:bg-background" - placeholder="Workspace group" - /> -
- - - -
- {PROJECT_COLORS.map((color) => ( - - ))} -
- - - - -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx deleted file mode 100644 index f7bfbfbe8..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroupHeader.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useDrag, useDrop } from "react-dnd"; -import { useReorderProjects } from "renderer/react-query/projects"; -import { WorkspaceGroupContextMenu } from "./WorkspaceGroupContextMenu"; - -const PROJECT_GROUP_TYPE = "PROJECT_GROUP"; - -interface WorkspaceGroupHeaderProps { - projectId: string; - projectName: string; - projectColor: string; - isCollapsed: boolean; - index: number; - onToggleCollapse: () => void; -} - -export function WorkspaceGroupHeader({ - projectId, - projectName, - projectColor, - isCollapsed, - index, - onToggleCollapse, -}: WorkspaceGroupHeaderProps) { - const reorderProjects = useReorderProjects(); - - const [{ isDragging }, drag] = useDrag( - () => ({ - type: PROJECT_GROUP_TYPE, - item: { projectId, index }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }), - [projectId, index], - ); - - const [{ isOver }, drop] = useDrop( - () => ({ - accept: PROJECT_GROUP_TYPE, - hover: (item: { projectId: string; index: number }) => { - if (item.index !== index) { - reorderProjects.mutate({ - fromIndex: item.index, - toIndex: index, - }); - item.index = index; - } - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - }), - }), - [index, reorderProjects], - ); - - return ( - -
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx deleted file mode 100644 index 938ab972e..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ /dev/null @@ -1,387 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { Input } from "@superset/ui/input"; -import { toast } from "@superset/ui/sonner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { cn } from "@superset/ui/utils"; -import { useState } from "react"; -import { useDrag, useDrop } from "react-dnd"; -import { HiMiniXMark } from "react-icons/hi2"; -import { LuGitBranch, LuLoader } from "react-icons/lu"; -import { trpc } from "renderer/lib/trpc"; -import { - useDeleteWorkspace, - useReorderWorkspaces, - useSetActiveWorkspace, -} from "renderer/react-query/workspaces"; -import { useCloseSettings } from "renderer/stores/app-state"; -import { useTabsStore } from "renderer/stores/tabs/store"; -import { - useHasWorkspaceFailed, - useIsWorkspaceInitializing, - useWorkspaceInitProgress, -} from "renderer/stores/workspace-init"; -import { BranchSwitcher } from "./BranchSwitcher"; -import { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; -import { useWorkspaceRename } from "./useWorkspaceRename"; -import { WorkspaceItemContextMenu } from "./WorkspaceItemContextMenu"; - -const WORKSPACE_TYPE = "WORKSPACE"; - -interface WorkspaceItemProps { - id: string; - projectId: string; - worktreePath: string; - workspaceType?: "worktree" | "branch"; - branch?: string; - title: string; - isActive: boolean; - index: number; - width: number; - onMouseEnter?: () => void; - onMouseLeave?: () => void; -} - -export function WorkspaceItem({ - id, - projectId, - worktreePath, - workspaceType = "worktree", - branch, - title, - isActive, - index, - width, - onMouseEnter, - onMouseLeave, -}: WorkspaceItemProps) { - const isBranchWorkspace = workspaceType === "branch"; - const setActive = useSetActiveWorkspace(); - const reorderWorkspaces = useReorderWorkspaces(); - const deleteWorkspace = useDeleteWorkspace(); - const closeSettings = useCloseSettings(); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const tabs = useTabsStore((s) => s.tabs); - const panes = useTabsStore((s) => s.panes); - const rename = useWorkspaceRename(id, title); - - // Workspace initialization state - const isInitializing = useIsWorkspaceInitializing(id); - const hasFailed = useHasWorkspaceFailed(id); - const initProgress = useWorkspaceInitProgress(id); - - // Query to check if workspace is empty - only enabled when needed - const canDeleteQuery = trpc.workspaces.canDelete.useQuery( - { id }, - { enabled: false }, - ); - - const handleDeleteClick = async () => { - // Prevent double-clicks and race conditions - if (deleteWorkspace.isPending || canDeleteQuery.isFetching) return; - - try { - // Always fetch fresh data before deciding - const { data: canDeleteData } = await canDeleteQuery.refetch(); - - // For branch workspaces, only show dialog if there are active terminals - // (no destructive action - branch stays in repo) - if (isBranchWorkspace) { - if ( - canDeleteData?.activeTerminalCount && - canDeleteData.activeTerminalCount > 0 - ) { - setShowDeleteDialog(true); - } else { - // Close directly without confirmation - toast.promise(deleteWorkspace.mutateAsync({ id }), { - loading: `Closing "${title}"...`, - success: `Workspace "${title}" closed`, - error: (error) => - error instanceof Error - ? `Failed to close workspace: ${error.message}` - : "Failed to close workspace", - }); - } - return; - } - - // For worktree workspaces, check all conditions - const isEmpty = - canDeleteData?.canDelete && - canDeleteData.activeTerminalCount === 0 && - !canDeleteData.warning && - !canDeleteData.hasChanges && - !canDeleteData.hasUnpushedCommits; - - if (isEmpty) { - // Delete directly without confirmation - toast.promise(deleteWorkspace.mutateAsync({ id }), { - loading: `Deleting "${title}"...`, - success: `Workspace "${title}" deleted`, - error: (error) => - error instanceof Error - ? `Failed to delete workspace: ${error.message}` - : "Failed to delete workspace", - }); - } else { - // Show confirmation dialog - setShowDeleteDialog(true); - } - } catch { - // On error checking status, show dialog for user to decide - setShowDeleteDialog(true); - } - }; - - // Check if any pane in tabs belonging to this workspace needs attention - const workspaceTabs = tabs.filter((t) => t.workspaceId === id); - const workspacePaneIds = new Set( - workspaceTabs.flatMap((t) => { - // Extract pane IDs from the layout (which is a MosaicNode) - const collectPaneIds = (node: unknown): string[] => { - if (typeof node === "string") return [node]; - if ( - node && - typeof node === "object" && - "first" in node && - "second" in node - ) { - const branch = node as { first: unknown; second: unknown }; - return [ - ...collectPaneIds(branch.first), - ...collectPaneIds(branch.second), - ]; - } - return []; - }; - return collectPaneIds(t.layout); - }), - ); - const needsAttention = Object.values(panes) - .filter((p) => workspacePaneIds.has(p.id)) - .some((p) => p.needsAttention); - - const [{ isDragging }, drag] = useDrag( - () => ({ - type: WORKSPACE_TYPE, - item: { id, projectId, index }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }), - [id, projectId, index], - ); - - const [, drop] = useDrop({ - accept: WORKSPACE_TYPE, - hover: (item: { id: string; projectId: string; index: number }) => { - // Only allow reordering within the same project - if (item.projectId === projectId && item.index !== index) { - reorderWorkspaces.mutate({ - projectId, - fromIndex: item.index, - toIndex: index, - }); - item.index = index; - } - }, - }); - - return ( - <> - -
- {/* Main workspace button */} - - - {/* Only show close button for worktree workspaces, disabled while initializing */} - {!isBranchWorkspace && ( - - - - - - {isInitializing - ? "Cannot delete while initializing" - : "Delete workspace"} - - - )} -
-
- - - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx deleted file mode 100644 index 142b12651..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItemContextMenu.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@superset/ui/hover-card"; -import type { ReactNode } from "react"; -import { trpc } from "renderer/lib/trpc"; -import { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; - -interface WorkspaceItemContextMenuProps { - children: ReactNode; - workspaceId: string; - worktreePath: string; - workspaceAlias?: string; - onRename: () => void; - canRename?: boolean; - showHoverCard?: boolean; -} - -export function WorkspaceItemContextMenu({ - children, - workspaceId, - worktreePath, - workspaceAlias, - onRename, - canRename = true, - showHoverCard = true, -}: WorkspaceItemContextMenuProps) { - const openInFinder = trpc.external.openInFinder.useMutation(); - - const handleOpenInFinder = () => { - if (worktreePath) { - openInFinder.mutate(worktreePath); - } - }; - - // For branch workspaces, just show context menu without hover card - if (!showHoverCard) { - return ( - - {children} - - {canRename && ( - <> - Rename - - - )} - - Open in Finder - - - - ); - } - - return ( - - - - {children} - - - {canRename && ( - <> - Rename - - - )} - - Open in Finder - - - - - - - - ); -} 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 deleted file mode 100644 index 27c585512..000000000 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { Fragment, useCallback, useEffect, useRef, useState } from "react"; -import { trpc } from "renderer/lib/trpc"; -import { - useCreateBranchWorkspace, - useSetActiveWorkspace, -} from "renderer/react-query/workspaces"; -import { - useCurrentView, - useIsSettingsTabOpen, -} from "renderer/stores/app-state"; -import { useAppHotkey } from "renderer/stores/hotkeys"; -import { CreateWorkspaceButton } from "./CreateWorkspaceButton"; -import { SettingsTab } from "./SettingsTab"; -import { WorkspaceGroup } from "./WorkspaceGroup"; - -const MIN_WORKSPACE_WIDTH = 60; -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(); - const currentView = useCurrentView(); - const isSettingsTabOpen = useIsSettingsTabOpen(); - const isSettingsActive = currentView === "settings"; - const containerRef = useRef(null); - const scrollRef = useRef(null); - const [showStartFade, setShowStartFade] = useState(false); - const [showEndFade, setShowEndFade] = useState(false); - const [workspaceWidth, setWorkspaceWidth] = useState(MAX_WORKSPACE_WIDTH); - const [hoveredWorkspaceId, setHoveredWorkspaceId] = useState( - 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); - - const handleWorkspaceSwitch = useCallback( - (index: number) => { - const workspace = allWorkspaces[index]; - 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]); - - useAppHotkey( - "JUMP_TO_WORKSPACE_1", - () => handleWorkspaceSwitch(0), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_2", - () => handleWorkspaceSwitch(1), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_3", - () => handleWorkspaceSwitch(2), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_4", - () => handleWorkspaceSwitch(3), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_5", - () => handleWorkspaceSwitch(4), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_6", - () => handleWorkspaceSwitch(5), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_7", - () => handleWorkspaceSwitch(6), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_8", - () => handleWorkspaceSwitch(7), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey( - "JUMP_TO_WORKSPACE_9", - () => handleWorkspaceSwitch(8), - undefined, - [handleWorkspaceSwitch], - ); - useAppHotkey("PREV_WORKSPACE", handlePrevWorkspace, undefined, [ - handlePrevWorkspace, - ]); - useAppHotkey("NEXT_WORKSPACE", handleNextWorkspace, undefined, [ - handleNextWorkspace, - ]); - - useEffect(() => { - const checkScroll = () => { - if (!scrollRef.current) return; - - const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; - setShowStartFade(scrollLeft > 0); - setShowEndFade(scrollLeft < scrollWidth - clientWidth - 1); - }; - - const updateWorkspaceWidth = () => { - if (!containerRef.current) return; - - const containerWidth = containerRef.current.offsetWidth; - const availableWidth = containerWidth - ADD_BUTTON_WIDTH; - - // Calculate width: fill available space but respect min/max - const calculatedWidth = Math.max( - MIN_WORKSPACE_WIDTH, - Math.min(MAX_WORKSPACE_WIDTH, availableWidth / allWorkspaces.length), - ); - setWorkspaceWidth(calculatedWidth); - }; - - checkScroll(); - updateWorkspaceWidth(); - - const scrollElement = scrollRef.current; - if (scrollElement) { - scrollElement.addEventListener("scroll", checkScroll); - } - - window.addEventListener("resize", updateWorkspaceWidth); - - return () => { - if (scrollElement) { - scrollElement.removeEventListener("scroll", checkScroll); - } - window.removeEventListener("resize", updateWorkspaceWidth); - }; - }, [allWorkspaces]); - - return ( -
-
-
- {groups.map((group, groupIndex) => ( - - - {groupIndex < groups.length - 1 && ( -
-
-
- )} - - ))} - {isSettingsTabOpen && ( - <> - {groups.length > 0 && ( -
-
-
- )} - - - )} -
- - {/* Left fade for scroll indication */} - {showStartFade && ( -
- )} - - {/* Right side: gradient fade + button container */} -
- {/* Gradient fade - only show when content overflows */} - {showEndFade && ( -
- )} - {/* Button with solid background */} -
- -
-
-
-
- ); -} 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 2875da88b..03e7f8838 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/index.tsx @@ -1,26 +1,36 @@ import { trpc } from "renderer/lib/trpc"; import { AvatarDropdown } from "../AvatarDropdown"; -import { SidebarControl } from "./SidebarControl"; import { WindowControls } from "./WindowControls"; -import { WorkspacesTabs } from "./WorkspaceTabs"; +import { WorkspaceSidebarControl } from "./WorkspaceSidebarControl"; export function TopBar() { const { data: platform } = trpc.window.getPlatform.useQuery(); - const isMac = platform === "darwin"; + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + // Default to Mac layout while loading to avoid overlap with traffic lights + const isMac = platform === undefined || platform === "darwin"; + return ( -
+
- +
-
- + +
+ {activeWorkspace && ( + + {activeWorkspace.project?.name ?? "Workspace"} + / + {activeWorkspace.name} + + )}
-
+ +
{!isMac && }
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PortsList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/PortsList.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/PortsList.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/index.ts new file mode 100644 index 000000000..7f04b50ce --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/PortsList/index.ts @@ -0,0 +1 @@ +export { PortsList } from "./PortsList"; 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 000000000..ec36067e4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -0,0 +1,31 @@ +import { cn } from "@superset/ui/utils"; + +interface ProjectHeaderProps { + projectName: string; + isCollapsed: boolean; + onToggleCollapse: () => void; + workspaceCount: number; +} + +export function ProjectHeader({ + projectName, + 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 000000000..fe109d997 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -0,0 +1,140 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { AnimatePresence, motion } from "framer-motion"; +import { useState } from "react"; +import { HiMiniPlus, HiOutlineBolt } from "react-icons/hi2"; +import { useCreateWorkspace } from "renderer/react-query/workspaces"; +import { useWorkspaceSidebarStore } from "renderer/stores"; +import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; +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; + isUnread: boolean; +} + +interface ProjectSectionProps { + projectId: string; + projectName: string; + workspaces: Workspace[]; + activeWorkspaceId: string | null; + /** Base index for keyboard shortcuts (0-based) */ + shortcutBaseIndex: number; +} + +export function ProjectSection({ + projectId, + projectName, + workspaces, + activeWorkspaceId, + shortcutBaseIndex, +}: ProjectSectionProps) { + const [dropdownOpen, setDropdownOpen] = useState(false); + const { isProjectCollapsed, toggleProjectCollapsed } = + useWorkspaceSidebarStore(); + const createWorkspace = useCreateWorkspace(); + const openModal = useOpenNewWorkspaceModal(); + + const isCollapsed = isProjectCollapsed(projectId); + + const handleQuickCreate = () => { + setDropdownOpen(false); + toast.promise(createWorkspace.mutateAsync({ projectId }), { + loading: "Creating workspace...", + success: "Workspace created", + error: (err) => + err instanceof Error ? err.message : "Failed to create workspace", + }); + }; + + const handleNewWorkspace = () => { + setDropdownOpen(false); + openModal(projectId); + }; + + return ( +
+ toggleProjectCollapsed(projectId)} + workspaceCount={workspaces.length} + /> + + + {!isCollapsed && ( + +
+ {workspaces.map((workspace, index) => ( + + ))} + + + + + + + + New Workspace + + + + Quick Create + + + +
+
+ )} +
+
+ ); +} 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 000000000..2111af01d --- /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 000000000..526fa283d --- /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 000000000..a2758d40b --- /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 000000000..c7fb51da4 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/WorkspaceListItem.tsx @@ -0,0 +1,376 @@ +import { Button } from "@superset/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@superset/ui/hover-card"; +import { Input } from "@superset/ui/input"; +import { toast } from "@superset/ui/sonner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { useDrag, useDrop } from "react-dnd"; +import { HiMiniXMark } from "react-icons/hi2"; +import { LuEye, LuEyeOff, LuGitBranch } from "react-icons/lu"; +import { trpc } from "renderer/lib/trpc"; +import { + useReorderWorkspaces, + useSetActiveWorkspace, + useWorkspaceDeleteHandler, +} from "renderer/react-query/workspaces"; +import { useWorkspaceRename } from "renderer/screens/main/hooks/useWorkspaceRename"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { extractPaneIdsFromLayout } from "renderer/stores/tabs/utils"; +import { + BranchSwitcher, + DeleteWorkspaceDialog, + WorkspaceHoverCardContent, +} from "./components"; +import { + GITHUB_STATUS_STALE_TIME, + HOVER_CARD_CLOSE_DELAY, + HOVER_CARD_OPEN_DELAY, + MAX_KEYBOARD_SHORTCUT_INDEX, +} from "./constants"; +import { WorkspaceDiffStats } from "./WorkspaceDiffStats"; +import { WorkspaceStatusBadge } from "./WorkspaceStatusBadge"; + +const WORKSPACE_TYPE = "WORKSPACE"; + +interface WorkspaceListItemProps { + id: string; + projectId: string; + worktreePath: string; + name: string; + branch: string; + type: "worktree" | "branch"; + isActive: boolean; + isUnread?: boolean; + index: number; + shortcutIndex?: number; +} + +export function WorkspaceListItem({ + id, + projectId, + worktreePath, + name, + branch, + type, + isActive, + isUnread = false, + index, + shortcutIndex, +}: WorkspaceListItemProps) { + const isBranchWorkspace = type === "branch"; + const setActiveWorkspace = useSetActiveWorkspace(); + const reorderWorkspaces = useReorderWorkspaces(); + const [hasHovered, setHasHovered] = useState(false); + const rename = useWorkspaceRename(id, name); + const tabs = useTabsStore((s) => s.tabs); + const panes = useTabsStore((s) => s.panes); + const clearWorkspaceAttention = useTabsStore( + (s) => s.clearWorkspaceAttention, + ); + const utils = trpc.useUtils(); + const openInFinder = trpc.external.openInFinder.useMutation({ + onError: (error) => toast.error(`Failed to open: ${error.message}`), + }); + const setUnread = trpc.workspaces.setUnread.useMutation({ + onSuccess: () => { + utils.workspaces.getAllGrouped.invalidate(); + }, + onError: (error) => + toast.error(`Failed to update unread status: ${error.message}`), + }); + + // Shared delete logic + const { showDeleteDialog, setShowDeleteDialog, handleDeleteClick } = + useWorkspaceDeleteHandler(); + + // 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: GITHUB_STATUS_STALE_TIME, + }, + ); + + // Check if any pane in tabs belonging to this workspace needs attention (agent notifications) + const workspaceTabs = tabs.filter((t) => t.workspaceId === id); + const workspacePaneIds = new Set( + workspaceTabs.flatMap((t) => extractPaneIdsFromLayout(t.layout)), + ); + const hasPaneAttention = Object.values(panes) + .filter((p) => p != null && workspacePaneIds.has(p.id)) + .some((p) => p.needsAttention); + + // Show indicator if workspace is manually marked as unread OR has pane-level attention + const needsAttention = isUnread || hasPaneAttention; + + const handleClick = () => { + if (!rename.isRenaming) { + setActiveWorkspace.mutate({ id }); + clearWorkspaceAttention(id); + } + }; + + const handleMouseEnter = () => { + if (!hasHovered) { + setHasHovered(true); + } + }; + + const handleOpenInFinder = () => { + if (worktreePath) { + openInFinder.mutate(worktreePath); + } + }; + + const handleToggleUnread = () => { + setUnread.mutate({ id, isUnread: !isUnread }); + }; + + // Drag and drop + const [{ isDragging }, drag] = useDrag( + () => ({ + type: WORKSPACE_TYPE, + item: { id, projectId, index }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }), + [id, projectId, index], + ); + + const [, drop] = useDrop({ + accept: WORKSPACE_TYPE, + hover: (item: { id: string; projectId: string; index: number }) => { + if (item.projectId === projectId && item.index !== index) { + reorderWorkspaces.mutate( + { + projectId, + fromIndex: item.index, + toIndex: index, + }, + { + onError: (error) => + toast.error(`Failed to reorder workspace: ${error.message}`), + }, + ); + item.index = index; + } + }, + }); + + const pr = githubStatus?.pr; + const showDiffStats = pr && (pr.additions > 0 || pr.deletions > 0); + + const content = ( + + + + Close or delete + + + )} + + ); + + const unreadMenuItem = ( + + {isUnread ? ( + <> + + Mark as Read + + ) : ( + <> + + Mark as Unread + + )} + + ); + + // Wrap with context menu and hover card + if (isBranchWorkspace) { + return ( + <> + + {content} + + + Open in Finder + + + {unreadMenuItem} + + + + + ); + } + + return ( + <> + + + + {content} + + + + Rename + + + + Open in Finder + + + {unreadMenuItem} + + + + + + + + + ); +} 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 000000000..d6eb509d7 --- /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/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/BranchSwitcher.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/BranchSwitcher.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/BranchSwitcher/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/BranchSwitcher/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts new file mode 100644 index 000000000..369ca7198 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/DeleteWorkspaceDialog/index.ts @@ -0,0 +1 @@ +export { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/WorkspaceHoverCard.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/WorkspaceHoverCard.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksList/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ChecksSummary/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/PRStatusBadge.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/PRStatusBadge/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/PRStatusBadge/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/ReviewStatus.tsx diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ReviewStatus/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/components/ReviewStatus/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/index.ts similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/index.ts rename to apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/WorkspaceHoverCard/index.ts diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts new file mode 100644 index 000000000..282bf9f9a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/components/index.ts @@ -0,0 +1,3 @@ +export { BranchSwitcher } from "./BranchSwitcher"; +export { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; +export { WorkspaceHoverCardContent } from "./WorkspaceHoverCard"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts new file mode 100644 index 000000000..b6768dfb7 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceListItem/constants.ts @@ -0,0 +1,15 @@ +/** + * Constants for workspace list item behavior + */ + +/** Maximum index for keyboard shortcuts (Cmd+1 through Cmd+9) */ +export const MAX_KEYBOARD_SHORTCUT_INDEX = 9; + +/** Stale time for GitHub status queries (30 seconds) */ +export const GITHUB_STATUS_STALE_TIME = 30_000; + +/** Delay before showing hover card (ms) */ +export const HOVER_CARD_OPEN_DELAY = 400; + +/** Delay before hiding hover card (ms) */ +export const HOVER_CARD_CLOSE_DELAY = 100; 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 000000000..4dd9ef18a --- /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 000000000..805144283 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -0,0 +1,53 @@ +import { useMemo } from "react"; +import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; +import { PortsList } from "./PortsList"; +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 using cumulative offsets + const projectShortcutIndices = useMemo( + () => + groups.reduce<{ indices: number[]; cumulative: number }>( + (acc, group) => ({ + indices: [...acc.indices, acc.cumulative], + cumulative: acc.cumulative + group.workspaces.length, + }), + { indices: [], cumulative: 0 }, + ).indices, + [groups], + ); + + 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 000000000..99c4117a9 --- /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 000000000..b51c2ab92 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader.tsx @@ -0,0 +1,12 @@ +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 000000000..d8dc22673 --- /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/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx index abcc51d51..e13ba8d99 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/DiffViewer.tsx @@ -1,6 +1,6 @@ import { DiffEditor, type DiffOnMount } from "@monaco-editor/react"; import type * as Monaco from "monaco-editor"; -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { LuLoader } from "react-icons/lu"; import { SUPERSET_THEME, @@ -9,7 +9,7 @@ import { import type { DiffViewMode, FileContents } from "shared/changes-types"; import { registerCopyPathLineAction, - registerSaveCommand, + registerSaveAction, } from "./editor-actions"; interface DiffViewerProps { @@ -18,6 +18,7 @@ interface DiffViewerProps { filePath: string; editable?: boolean; onSave?: (content: string) => void; + onChange?: (content: string) => void; } export function DiffViewer({ @@ -26,17 +27,23 @@ export function DiffViewer({ filePath, editable = false, onSave, + onChange, }: DiffViewerProps) { const isMonacoReady = useMonacoReady(); const modifiedEditorRef = useRef( null, ); + // Track when editor is mounted to trigger effects at the right time + const [isEditorMounted, setIsEditorMounted] = useState(false); const handleSave = useCallback(() => { if (!editable || !onSave || !modifiedEditorRef.current) return; onSave(modifiedEditorRef.current.getValue()); }, [editable, onSave]); + // Store disposable for content change listener cleanup + const changeListenerRef = useRef(null); + const handleMount: DiffOnMount = useCallback( (editor) => { const originalEditor = editor.getOriginalEditor(); @@ -46,13 +53,43 @@ export function DiffViewer({ registerCopyPathLineAction(originalEditor, filePath); registerCopyPathLineAction(modifiedEditor, filePath); - if (editable) { - registerSaveCommand(modifiedEditor, handleSave); - } + setIsEditorMounted(true); }, - [editable, handleSave, filePath], + [filePath], ); + // Update readOnly and register save action when editable changes or editor mounts + // Using addAction with an ID allows replacing the action on subsequent calls + useEffect(() => { + if (!isEditorMounted || !modifiedEditorRef.current) return; + + modifiedEditorRef.current.updateOptions({ readOnly: !editable }); + + if (editable) { + registerSaveAction(modifiedEditorRef.current, handleSave); + } + }, [isEditorMounted, editable, handleSave]); + + // Set up content change listener for dirty tracking + useEffect(() => { + if (!isEditorMounted || !modifiedEditorRef.current || !onChange) return; + + // Clean up previous listener + changeListenerRef.current?.dispose(); + + changeListenerRef.current = + modifiedEditorRef.current.onDidChangeModelContent(() => { + if (modifiedEditorRef.current) { + onChange(modifiedEditorRef.current.getValue()); + } + }); + + return () => { + changeListenerRef.current?.dispose(); + changeListenerRef.current = null; + }; + }, [isEditorMounted, onChange]); + if (!isMonacoReady) { return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts index 7a5bb9c59..90d6c4b78 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ChangesContent/components/DiffViewer/editor-actions.ts @@ -28,9 +28,15 @@ export function registerCopyPathLineAction( }); } -export function registerSaveCommand( +export function registerSaveAction( editor: Monaco.editor.IStandaloneCodeEditor, onSave: () => void, ) { - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, onSave); + // Using addAction with an ID allows replacing the action on subsequent calls + editor.addAction({ + id: "save-file", + label: "Save File", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + run: onSave, + }); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx new file mode 100644 index 000000000..398605b82 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from "react"; + +interface ContentHeaderProps { + /** Optional leading action (e.g., SidebarControl) */ + leadingAction?: ReactNode; + /** Mode-specific header content (e.g., GroupStrip or file info) */ + children: ReactNode; + /** Optional trailing action (e.g., WorkspaceControls) */ + trailingAction?: ReactNode; +} + +export function ContentHeader({ + leadingAction, + children, + trailingAction, +}: ContentHeaderProps) { + return ( +
+ {leadingAction && ( +
{leadingAction}
+ )} +
{children}
+ {trailingAction && ( +
{trailingAction}
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts new file mode 100644 index 000000000..26fb6ccbc --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/index.ts @@ -0,0 +1 @@ +export { ContentHeader } from "./ContentHeader"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx index 0f9a435a8..daaff3366 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/EmptyTabView.tsx @@ -3,16 +3,16 @@ import { HiMiniCommandLine } from "react-icons/hi2"; import { useHotkeyDisplay } from "renderer/stores/hotkeys"; export function EmptyTabView() { - const newTerminalDisplay = useHotkeyDisplay("NEW_TERMINAL"); + const newGroupDisplay = useHotkeyDisplay("NEW_GROUP"); const openInAppDisplay = useHotkeyDisplay("OPEN_IN_APP"); const shortcuts = [ - { label: "New Terminal", display: newTerminalDisplay }, + { label: "New Group", display: newGroupDisplay }, { label: "Open in App", display: openInAppDisplay }, ]; return ( -
+
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx new file mode 100644 index 000000000..678897855 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupItem.tsx @@ -0,0 +1,79 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { HiMiniXMark } from "react-icons/hi2"; +import type { Tab } from "renderer/stores/tabs/types"; +import { getTabDisplayName } from "renderer/stores/tabs/utils"; + +interface GroupItemProps { + tab: Tab; + isActive: boolean; + needsAttention: boolean; + onSelect: () => void; + onClose: () => void; +} + +export function GroupItem({ + tab, + isActive, + needsAttention, + onSelect, + onClose, +}: GroupItemProps) { + const displayName = getTabDisplayName(tab); + + return ( +
+ + + + + + {displayName} + + + + + + + + Close group + + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx new file mode 100644 index 000000000..9e068c97f --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -0,0 +1,97 @@ +import { Button } from "@superset/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { useMemo } from "react"; +import { HiMiniPlus } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { GroupItem } from "./GroupItem"; + +export function GroupStrip() { + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const activeWorkspaceId = activeWorkspace?.id; + + const allTabs = useTabsStore((s) => s.tabs); + const panes = useTabsStore((s) => s.panes); + const activeTabIds = useTabsStore((s) => s.activeTabIds); + const addTab = useTabsStore((s) => s.addTab); + const removeTab = useTabsStore((s) => s.removeTab); + const setActiveTab = useTabsStore((s) => s.setActiveTab); + + const tabs = useMemo( + () => + activeWorkspaceId + ? allTabs.filter((tab) => tab.workspaceId === activeWorkspaceId) + : [], + [activeWorkspaceId, allTabs], + ); + + const activeTabId = activeWorkspaceId + ? activeTabIds[activeWorkspaceId] + : null; + + // Check which tabs have panes that need attention + const tabsWithAttention = useMemo(() => { + const result = new Set(); + for (const pane of Object.values(panes)) { + if (pane.needsAttention) { + result.add(pane.tabId); + } + } + return result; + }, [panes]); + + const handleAddGroup = () => { + if (activeWorkspaceId) { + addTab(activeWorkspaceId); + } + }; + + const handleSelectGroup = (tabId: string) => { + if (activeWorkspaceId) { + setActiveTab(activeWorkspaceId, tabId); + } + }; + + const handleCloseGroup = (tabId: string) => { + removeTab(tabId); + }; + + return ( +
+ {tabs.length > 0 && ( +
+ {tabs.map((tab) => ( +
+ handleSelectGroup(tab.id)} + onClose={() => handleCloseGroup(tab.id)} + /> +
+ ))} +
+ )} + + + + + + New Group + + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts new file mode 100644 index 000000000..e905a6c8b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/index.ts @@ -0,0 +1 @@ +export { GroupStrip } from "./GroupStrip"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx new file mode 100644 index 000000000..5ede82296 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -0,0 +1,790 @@ +import Editor, { type OnMount } from "@monaco-editor/react"; +import { Badge } from "@superset/ui/badge"; +import { ToggleGroup, ToggleGroupItem } from "@superset/ui/toggle-group"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import type * as Monaco from "monaco-editor"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + HiMiniLockClosed, + HiMiniLockOpen, + HiMiniPencil, + HiMiniXMark, +} from "react-icons/hi2"; +import { LuLoader } from "react-icons/lu"; +import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; +import type { MosaicBranch } from "react-mosaic-component"; +import { MosaicWindow } from "react-mosaic-component"; +import { MarkdownRenderer } from "renderer/components/MarkdownRenderer"; +import { + monaco, + SUPERSET_THEME, + useMonacoReady, +} from "renderer/contexts/MonacoProvider"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Pane } from "renderer/stores/tabs/types"; +import type { FileViewerMode } from "shared/tabs-types"; +import { DiffViewer } from "../../../ChangesContent/components/DiffViewer"; +import { UnsavedChangesDialog } from "./UnsavedChangesDialog"; + +type SplitOrientation = "vertical" | "horizontal"; + +/** Client-side language detection for Monaco editor */ +function detectLanguage(filePath: string): string { + const ext = filePath.split(".").pop()?.toLowerCase() ?? ""; + const languageMap: Record = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + json: "json", + md: "markdown", + mdx: "markdown", + css: "css", + scss: "scss", + less: "less", + html: "html", + xml: "xml", + yaml: "yaml", + yml: "yaml", + py: "python", + rs: "rust", + go: "go", + java: "java", + kt: "kotlin", + swift: "swift", + c: "c", + cpp: "cpp", + h: "c", + hpp: "cpp", + sh: "shell", + bash: "shell", + zsh: "shell", + sql: "sql", + graphql: "graphql", + gql: "graphql", + }; + return languageMap[ext] ?? "plaintext"; +} + +interface FileViewerPaneProps { + paneId: string; + path: MosaicBranch[]; + pane: Pane; + isActive: boolean; + tabId: string; + worktreePath: string; + splitPaneAuto: ( + tabId: string, + sourcePaneId: string, + dimensions: { width: number; height: number }, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (tabId: string, paneId: string) => void; +} + +export function FileViewerPane({ + paneId, + path, + pane, + isActive, + tabId, + worktreePath, + splitPaneAuto, + removePane, + setFocusedPane, +}: FileViewerPaneProps) { + const containerRef = useRef(null); + const [splitOrientation, setSplitOrientation] = + useState("vertical"); + const isMonacoReady = useMonacoReady(); + const editorRef = useRef(null); + const [isDirty, setIsDirty] = useState(false); + const originalContentRef = useRef(""); + // Store draft content to preserve edits across view mode switches + const draftContentRef = useRef(null); + // Track original diff modified content for dirty comparison + const originalDiffContentRef = useRef(""); + // Track current diff content for save & switch + const currentDiffContentRef = useRef(""); + // Dialog state for unsaved changes prompt + const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); + const [isSavingAndSwitching, setIsSavingAndSwitching] = useState(false); + const pendingModeRef = useRef(null); + const utils = trpc.useUtils(); + + // Track container dimensions for auto-split orientation + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const updateOrientation = () => { + const { width, height } = container.getBoundingClientRect(); + setSplitOrientation(width >= height ? "vertical" : "horizontal"); + }; + + updateOrientation(); + + const resizeObserver = new ResizeObserver(updateOrientation); + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + const fileViewer = pane.fileViewer; + + // Extract values with defaults for hooks (hooks must be called unconditionally) + const filePath = fileViewer?.filePath ?? ""; + const viewMode = fileViewer?.viewMode ?? "raw"; + const isLocked = fileViewer?.isLocked ?? false; + const diffCategory = fileViewer?.diffCategory; + const commitHash = fileViewer?.commitHash; + const oldPath = fileViewer?.oldPath; + // Line/column for initial scroll position (raw mode only, applied once) + const initialLine = fileViewer?.initialLine; + const initialColumn = fileViewer?.initialColumn; + + // Fetch branch info for against-base diffs (P1-1) + const { data: branchData } = trpc.changes.getBranches.useQuery( + { worktreePath }, + { enabled: !!worktreePath && diffCategory === "against-base" }, + ); + const effectiveBaseBranch = branchData?.defaultBranch ?? "main"; + + // Track if we're saving from raw mode to know when to clear draft + const savingFromRawRef = useRef(false); + // Track content being saved from diff mode for updating originalDiffContentRef + const savingDiffContentRef = useRef(null); + + // Track if we've applied initial line/column navigation (reset on file change) + const hasAppliedInitialLocationRef = useRef(false); + + // Save mutation + const saveFileMutation = trpc.changes.saveFile.useMutation({ + onSuccess: () => { + setIsDirty(false); + // Update original content to current content after save + if (editorRef.current) { + originalContentRef.current = editorRef.current.getValue(); + } + // Update diff baseline if we saved from Diff mode + if (savingDiffContentRef.current !== null) { + originalDiffContentRef.current = savingDiffContentRef.current; + savingDiffContentRef.current = null; + } + // P1: Only clear draft if we saved from Raw mode (we saved the draft content) + // Don't clear if saving from Diff mode as that would discard Raw edits + if (savingFromRawRef.current) { + draftContentRef.current = null; + } + savingFromRawRef.current = false; + // Invalidate queries to refresh data + utils.changes.readWorkingFile.invalidate(); + utils.changes.getFileContents.invalidate(); + utils.changes.getStatus.invalidate(); + + // P1-2: Switch to unstaged view if saving from staged (edits become unstaged changes) + if (diffCategory === "staged") { + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + diffCategory: "unstaged", + }, + }, + }, + }); + } + } + }, + }); + + // Save handler for raw mode editor - returns promise for async save & switch + const handleSaveRaw = useCallback(async () => { + if (!editorRef.current || !filePath || !worktreePath) return; + // Mark that we're saving from Raw mode so onSuccess knows to clear draft + savingFromRawRef.current = true; + await saveFileMutation.mutateAsync({ + worktreePath, + filePath, + content: editorRef.current.getValue(), + }); + }, [worktreePath, filePath, saveFileMutation]); + + // Save handler for diff mode - returns promise for async save & switch + const handleSaveDiff = useCallback( + async (content: string) => { + if (!filePath || !worktreePath) return; + // Not saving from Raw mode - don't clear draft + savingFromRawRef.current = false; + // Track content for updating diff baseline on success + savingDiffContentRef.current = content; + await saveFileMutation.mutateAsync({ + worktreePath, + filePath, + content, + }); + }, + [worktreePath, filePath, saveFileMutation], + ); + + // Editor mount handler - set up Cmd+S keybinding + const handleEditorMount: OnMount = useCallback( + (editor) => { + editorRef.current = editor; + // Store original content for dirty tracking (only if not restoring draft) + // If we have draft content, originalContentRef is already set to the file content + if (!draftContentRef.current) { + originalContentRef.current = editor.getValue(); + } + // P1: Update dirty state based on restored draft content + setIsDirty(editor.getValue() !== originalContentRef.current); + + // Register save action with Cmd+S / Ctrl+S + editor.addAction({ + id: "save-file", + label: "Save File", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], + run: () => { + handleSaveRaw(); + }, + }); + }, + [handleSaveRaw], + ); + + // Track content changes for dirty state + const handleEditorChange = useCallback((value: string | undefined) => { + if (value === undefined) return; + // If baseline is empty, this is initial load after a mode switch - set baseline + if (originalContentRef.current === "") { + originalContentRef.current = value; + return; + } + setIsDirty(value !== originalContentRef.current); + }, []); + + // Reset dirty state, draft, and initial location tracking when file changes + // biome-ignore lint/correctness/useExhaustiveDependencies: Reset on file change only + useEffect(() => { + setIsDirty(false); + originalContentRef.current = ""; + draftContentRef.current = null; + hasAppliedInitialLocationRef.current = false; + }, [filePath]); + + // P1: Reset navigation flag when line/column changes (e.g., clicking same file from terminal with different line) + // biome-ignore lint/correctness/useExhaustiveDependencies: Only reset when coordinates change + useEffect(() => { + hasAppliedInitialLocationRef.current = false; + }, [initialLine, initialColumn]); + + // Fetch raw file content - always call hook, use enabled to control fetching + const { data: rawFileData, isLoading: isLoadingRaw } = + trpc.changes.readWorkingFile.useQuery( + { worktreePath, filePath }, + { + enabled: + !!fileViewer && viewMode !== "diff" && !!filePath && !!worktreePath, + }, + ); + + // Fetch diff content - always call hook, use enabled to control fetching + const { data: diffData, isLoading: isLoadingDiff } = + trpc.changes.getFileContents.useQuery( + { + worktreePath, + filePath, + oldPath, + category: diffCategory ?? "unstaged", + commitHash, + // P1-1: Pass defaultBranch for against-base diffs + defaultBranch: + diffCategory === "against-base" ? effectiveBaseBranch : undefined, + }, + { + enabled: + !!fileViewer && + viewMode === "diff" && + !!diffCategory && + !!filePath && + !!worktreePath, + }, + ); + + // P1-1: Update originalContentRef when raw content loads (dirty tracking fix) + // biome-ignore lint/correctness/useExhaustiveDependencies: Only update baseline when content loads + useEffect(() => { + if (rawFileData?.ok === true && !isDirty) { + originalContentRef.current = rawFileData.content; + } + }, [rawFileData]); + + // Update originalDiffContentRef when diff data loads + // biome-ignore lint/correctness/useExhaustiveDependencies: Only update baseline when diff loads + useEffect(() => { + if (diffData?.modified && !isDirty) { + originalDiffContentRef.current = diffData.modified; + } + }, [diffData]); + + // Handler for diff editor content changes + const handleDiffChange = useCallback((content: string) => { + currentDiffContentRef.current = content; + // If baseline is empty, this is initial mount after a mode switch - set baseline + if (originalDiffContentRef.current === "") { + originalDiffContentRef.current = content; + return; + } + setIsDirty(content !== originalDiffContentRef.current); + }, []); + + // Apply initial line/column navigation when raw content is ready + // NOTE: Line/column navigation only supported in raw mode. + // Diff mode has different line numbers between sides; rendered mode has no line concept. + useEffect(() => { + if ( + viewMode !== "raw" || + !editorRef.current || + !initialLine || + hasAppliedInitialLocationRef.current || + isLoadingRaw || + !rawFileData?.ok + ) { + return; + } + + const editor = editorRef.current; + const model = editor.getModel(); + if (!model) return; + + // Clamp to valid range to handle lines that exceed file length + const lineCount = model.getLineCount(); + const safeLine = Math.max(1, Math.min(initialLine, lineCount)); + const maxColumn = model.getLineMaxColumn(safeLine); + const safeColumn = Math.max(1, Math.min(initialColumn ?? 1, maxColumn)); + + const position = { lineNumber: safeLine, column: safeColumn }; + editor.setPosition(position); + editor.revealPositionInCenter(position); + editor.focus(); + + hasAppliedInitialLocationRef.current = true; + }, [viewMode, initialLine, initialColumn, isLoadingRaw, rawFileData]); + + // Early return AFTER hooks + if (!fileViewer) { + return ( + path={path} title=""> +
+ No file viewer state +
+ + ); + } + + const handleFocus = () => { + setFocusedPane(tabId, paneId); + }; + + const handleClosePane = (e: React.MouseEvent) => { + e.stopPropagation(); + removePane(paneId); + }; + + const handleSplitPane = (e: React.MouseEvent) => { + e.stopPropagation(); + const container = containerRef.current; + if (!container) return; + + const { width, height } = container.getBoundingClientRect(); + splitPaneAuto(tabId, paneId, { width, height }, path); + }; + + const handleToggleLock = () => { + // Update the pane's lock state in the store + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + isLocked: !currentPane.fileViewer.isLocked, + }, + }, + }, + }); + } + }; + + // Helper to switch view mode + const switchToMode = (newMode: FileViewerMode) => { + const panes = useTabsStore.getState().panes; + const currentPane = panes[paneId]; + if (currentPane?.fileViewer) { + useTabsStore.setState({ + panes: { + ...panes, + [paneId]: { + ...currentPane, + fileViewer: { + ...currentPane.fileViewer, + viewMode: newMode, + }, + }, + }, + }); + } + }; + + const handleViewModeChange = (value: string) => { + if (!value) return; + const newMode = value as FileViewerMode; + + // If switching away from an editable mode with unsaved changes, show dialog + // This covers both Raw → Diff/Rendered and Diff → Raw/Rendered + if (isDirty && newMode !== viewMode) { + pendingModeRef.current = newMode; + setShowUnsavedDialog(true); + return; + } + + switchToMode(newMode); + }; + + const handleSaveAndSwitch = async () => { + if (!pendingModeRef.current) return; + + setIsSavingAndSwitching(true); + try { + // Save based on current view mode + // Note: use !== undefined to allow saving empty files (empty string is valid) + if (viewMode === "raw" && editorRef.current) { + const savedContent = editorRef.current.getValue(); + await handleSaveRaw(); + // Update baseline to saved content so dirty state resets + originalContentRef.current = savedContent; + // Reset diff baseline so useEffect sets fresh baseline when diff loads + originalDiffContentRef.current = ""; + } else if ( + viewMode === "diff" && + currentDiffContentRef.current !== undefined + ) { + const savedContent = currentDiffContentRef.current; + await handleSaveDiff(savedContent); + // Update baseline to saved content so dirty state resets + originalDiffContentRef.current = savedContent; + // Reset raw baseline so useEffect sets fresh baseline when raw loads + originalContentRef.current = ""; + } + + // Reset dirty state after successful save + setIsDirty(false); + draftContentRef.current = null; + currentDiffContentRef.current = ""; + + // Only switch after save succeeds + switchToMode(pendingModeRef.current); + pendingModeRef.current = null; + setShowUnsavedDialog(false); + } catch (error) { + // Save failed - stay in current mode, dialog stays open + console.error("[FileViewerPane] Save failed:", error); + } finally { + setIsSavingAndSwitching(false); + } + }; + + const handleDiscardAndSwitch = () => { + if (!pendingModeRef.current) return; + + // Reset based on current view mode + if (viewMode === "raw" && editorRef.current) { + editorRef.current.setValue(originalContentRef.current); + } + // For diff mode, we just need to reset the dirty state + // The diff viewer will reload from the file when we switch back + + setIsDirty(false); + draftContentRef.current = null; + currentDiffContentRef.current = ""; + + // Switch to the pending mode + switchToMode(pendingModeRef.current); + pendingModeRef.current = null; + }; + + const fileName = filePath.split("/").pop() || filePath; + + // P1-3: Only allow editing for staged/unstaged diffs (not committed/against-main) + // P1: Also disable Diff editing when a Raw draft exists to prevent silent data loss + // User must go back to Raw mode to save their unsaved edits first + const hasDraft = draftContentRef.current !== null; + const isDiffEditable = + (diffCategory === "staged" || diffCategory === "unstaged") && !hasDraft; + + // Render content based on view mode + const renderContent = () => { + if (viewMode === "diff") { + if (isLoadingDiff) { + return ( +
+ Loading diff... +
+ ); + } + if (!diffData) { + return ( +
+ No diff available +
+ ); + } + return ( + + ); + } + + if (isLoadingRaw) { + return ( +
+ Loading... +
+ ); + } + + if (!rawFileData?.ok) { + const errorMessage = + rawFileData?.reason === "too-large" + ? "File is too large to preview" + : rawFileData?.reason === "binary" + ? "Binary file preview not supported" + : rawFileData?.reason === "outside-worktree" + ? "File is outside worktree" + : rawFileData?.reason === "symlink-escape" + ? "File is a symlink pointing outside worktree" + : "File not found"; + return ( +
+ {errorMessage} +
+ ); + } + + if (viewMode === "rendered") { + return ( +
+ +
+ ); + } + + // Raw mode - editable Monaco editor + if (!isMonacoReady) { + return ( +
+ + Loading editor... +
+ ); + } + + // P0-2: Key by filePath to force remount and fresh action registration + // P1: Use draft content if available (preserves edits across view mode switches) + return ( + + + Loading editor... +
+ } + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: "on", + fontSize: 13, + lineHeight: 20, + fontFamily: + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace", + padding: { top: 8, bottom: 8 }, + scrollbar: { + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + }, + }} + /> + ); + }; + + // Determine which view modes are available + // P1-2: Include .mdx for consistency with default view mode logic + const isMarkdown = + filePath.endsWith(".md") || + filePath.endsWith(".markdown") || + filePath.endsWith(".mdx"); + const hasDiff = !!diffCategory; + + const splitIcon = + splitOrientation === "vertical" ? ( + + ) : ( + + ); + + // Show editable badge only for editable modes + const showEditableBadge = + viewMode === "raw" || (viewMode === "diff" && isDiffEditable); + const isSaving = saveFileMutation.isPending; + + return ( + + path={path} + title="" + renderToolbar={() => ( +
+
+ + {isDirty && } + {fileName} + + {showEditableBadge && ( + + + {isSaving ? "Saving..." : "⌘S"} + + )} +
+
+ + {isMarkdown && ( + + Rendered + + )} + + Raw + + {hasDiff && ( + + Diff + + )} + + + + + + + Split pane + + + + + + + + {isLocked + ? "Unlock (allow file replacement)" + : "Lock (prevent file replacement)"} + + + + + + + + Close + + +
+
+ )} + className={isActive ? "mosaic-window-focused" : ""} + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: Focus handler */} +
+ {renderContent()} +
+ + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx new file mode 100644 index 000000000..98c47db44 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/UnsavedChangesDialog.tsx @@ -0,0 +1,74 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Button } from "@superset/ui/button"; +import { LuLoader } from "react-icons/lu"; + +interface UnsavedChangesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSaveAndSwitch: () => void; + onDiscardAndSwitch: () => void; + isSaving?: boolean; +} + +export function UnsavedChangesDialog({ + open, + onOpenChange, + onSaveAndSwitch, + onDiscardAndSwitch, + isSaving = false, +}: UnsavedChangesDialogProps) { + const handleSaveAndSwitch = (e: React.MouseEvent) => { + e.preventDefault(); + onSaveAndSwitch(); + // Don't close dialog - parent will close on success + }; + + const handleDiscardAndSwitch = (e: React.MouseEvent) => { + e.preventDefault(); + onDiscardAndSwitch(); + onOpenChange(false); + }; + + return ( + + + + Unsaved Changes + + You have unsaved changes. What would you like to do? + + + + Cancel + + + {isSaving ? ( + <> + + Saving... + + ) : ( + "Save & Switch" + )} + + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts new file mode 100644 index 000000000..96c33fa0b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/index.ts @@ -0,0 +1 @@ +export { FileViewerPane } from "./FileViewerPane"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx index b6df3608e..766f486ae 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/index.tsx @@ -8,6 +8,7 @@ import { type MosaicNode, } from "react-mosaic-component"; import { dragDropManager } from "renderer/lib/dnd"; +import { trpc } from "renderer/lib/trpc"; import { useTabsStore } from "renderer/stores/tabs/store"; import type { Pane, Tab } from "renderer/stores/tabs/types"; import { @@ -15,6 +16,7 @@ import { extractPaneIdsFromLayout, getPaneIdsForTab, } from "renderer/stores/tabs/utils"; +import { FileViewerPane } from "./FileViewerPane"; import { TabPane } from "./TabPane"; interface TabViewProps { @@ -35,6 +37,10 @@ export function TabView({ tab, panes }: TabViewProps) { const movePaneToNewTab = useTabsStore((s) => s.movePaneToNewTab); const allTabs = useTabsStore((s) => s.tabs); + // Get worktree path for file viewer panes + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const worktreePath = activeWorkspace?.worktreePath ?? ""; + // Get tabs in the same workspace for move targets const workspaceTabs = allTabs.filter( (t) => t.workspaceId === tab.workspaceId, @@ -90,6 +96,31 @@ export function TabView({ tab, panes }: TabViewProps) { ); } + // Route file-viewer panes to FileViewerPane component + if (pane.type === "file-viewer") { + if (!worktreePath) { + return ( +
+ Workspace path unavailable +
+ ); + } + return ( + + ); + } + + // Default: terminal panes return ( { const setTabAutoTitle = useTabsStore((s) => s.setTabAutoTitle); const updatePaneCwd = useTabsStore((s) => s.updatePaneCwd); const focusedPaneIds = useTabsStore((s) => s.focusedPaneIds); + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); const terminalTheme = useTerminalTheme(); // Ref for initial theme to avoid recreating terminal on theme change @@ -68,6 +71,76 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const { data: workspaceCwd } = trpc.terminal.getWorkspaceCwd.useQuery(workspaceId); + // Query terminal link behavior setting + const { data: terminalLinkBehavior } = + trpc.settings.getTerminalLinkBehavior.useQuery(); + + // Handler for file link clicks - uses current setting value + const handleFileLinkClick = useCallback( + (path: string, line?: number, column?: number) => { + const behavior = terminalLinkBehavior ?? "external-editor"; + + // Helper to open in external editor + const openInExternalEditor = () => { + trpcClient.external.openFileInEditor + .mutate({ + path, + line, + column, + cwd: workspaceCwd ?? undefined, + }) + .catch((error) => { + console.error( + "[Terminal] Failed to open file in editor:", + path, + error, + ); + toast.error("Failed to open file in editor", { + description: path, + }); + }); + }; + + if (behavior === "file-viewer") { + // If workspaceCwd is not loaded yet, fall back to external editor + // This prevents confusing errors when the workspace is still initializing + if (!workspaceCwd) { + console.warn( + "[Terminal] workspaceCwd not loaded, falling back to external editor", + ); + openInExternalEditor(); + return; + } + + // Normalize absolute paths to worktree-relative paths for file viewer + // File viewer expects relative paths, but terminal links can be absolute + let filePath = path; + // Use path boundary check to avoid incorrect prefix stripping + // e.g., /repo vs /repo-other should not match + if (path === workspaceCwd) { + filePath = "."; + } else if (path.startsWith(`${workspaceCwd}/`)) { + filePath = path.slice(workspaceCwd.length + 1); + } else if (path.startsWith("/")) { + // Absolute path outside workspace - show warning and don't attempt to open + toast.warning("File is outside the workspace", { + description: + "Switch to 'External editor' in Settings to open this file", + }); + return; + } + addFileViewerPane(workspaceId, { filePath, line, column }); + } else { + openInExternalEditor(); + } + }, + [terminalLinkBehavior, workspaceId, workspaceCwd, addFileViewerPane], + ); + + // Ref to avoid terminal recreation when callback changes + const handleFileLinkClickRef = useRef(handleFileLinkClick); + handleFileLinkClickRef.current = handleFileLinkClick; + // Seed cwd from initialCwd or workspace path (shell spawns there) // OSC-7 will override if/when the shell reports directory changes useEffect(() => { @@ -197,11 +270,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xterm, fitAddon, cleanup: cleanupQuerySuppression, - } = createTerminalInstance( - container, - workspaceCwd, - initialThemeRef.current, - ); + } = createTerminalInstance(container, { + cwd: workspaceCwd, + initialTheme: initialThemeRef.current, + onFileLinkClick: (path, line, column) => + handleFileLinkClickRef.current(path, line, column), + }); xtermRef.current = xterm; fitAddonRef.current = fitAddon; isExitedRef.current = false; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 3c92f907d..fbd1b980b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -93,19 +93,26 @@ function loadRenderer(xterm: XTerm): { dispose: () => void } { }; } +export interface CreateTerminalOptions { + cwd?: string; + initialTheme?: ITheme | null; + onFileLinkClick?: (path: string, line?: number, column?: number) => void; +} + export function createTerminalInstance( container: HTMLDivElement, - cwd?: string, - initialTheme?: ITheme | null, + options: CreateTerminalOptions = {}, ): { xterm: XTerm; fitAddon: FitAddon; cleanup: () => void; } { + const { cwd, initialTheme, onFileLinkClick } = options; + // Use provided theme, or fall back to localStorage-based default to prevent flash const theme = initialTheme ?? getDefaultTerminalTheme(); - const options = { ...TERMINAL_OPTIONS, theme }; - const xterm = new XTerm(options); + const terminalOptions = { ...TERMINAL_OPTIONS, theme }; + const xterm = new XTerm(terminalOptions); const fitAddon = new FitAddon(); const clipboardAddon = new ClipboardAddon(); @@ -149,20 +156,25 @@ export function createTerminalInstance( const filePathLinkProvider = new FilePathLinkProvider( xterm, (_event, path, line, column) => { - trpcClient.external.openFileInEditor - .mutate({ - path, - line, - column, - cwd, - }) - .catch((error) => { - console.error( - "[Terminal] Failed to open file in editor:", + if (onFileLinkClick) { + onFileLinkClick(path, line, column); + } else { + // Fallback to default behavior (external editor) + trpcClient.external.openFileInEditor + .mutate({ path, - error, - ); - }); + line, + column, + cwd, + }) + .catch((error) => { + console.error( + "[Terminal] Failed to open file in editor:", + path, + error, + ); + }); + } }, ); xterm.registerLinkProvider(filePathLinkProvider); @@ -233,7 +245,7 @@ export function setupPasteHandler( /** * Setup keyboard handling for xterm including: - * - Shortcut forwarding: App hotkeys are re-dispatched to document for react-hotkeys-hook + * - Shortcut forwarding: App hotkeys bubble to document where useAppHotkey listens * - Shift+Enter: Sends ESC+CR sequence (to avoid \ appearing in Claude Code while keeping line continuation behavior) * - Clear terminal: Uses the configured clear shortcut * diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index e04866eb6..fb3908f55 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -23,5 +23,9 @@ export function TabsContent() { return ; } - return ; + return ( +
+ +
+ ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index 1a2e20bd0..bc008618d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -1,19 +1,62 @@ -import { SidebarMode, useSidebarStore } from "renderer/stores"; +import { trpc } from "renderer/lib/trpc"; +import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; +import { SidebarControl } from "../../SidebarControl"; +import { WorkspaceControls } from "../../TopBar/WorkspaceControls"; import { ChangesContent } from "./ChangesContent"; +import { ContentHeader } from "./ContentHeader"; import { TabsContent } from "./TabsContent"; +import { GroupStrip } from "./TabsContent/GroupStrip"; export function ContentView() { - const { currentMode } = useSidebarStore(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const workspaceId = activeWorkspace?.id; - if (currentMode === SidebarMode.Changes) { + // Subscribe to the actual data, not just the getter function + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + + const viewMode = workspaceId + ? (viewModeByWorkspaceId[workspaceId] ?? "workbench") + : "workbench"; + + const showGroupStrip = viewMode === "workbench"; + + const workspaceControls = ( + + ); + + if (viewMode === "review") { return ( -
-
- +
+ } + trailingAction={workspaceControls} + > + {/* Review mode has no group tabs */} +
+ +
+
+ +
); } - return ; + return ( +
+ } + trailingAction={workspaceControls} + > + {showGroupStrip ? :
} + + +
+ ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx index ee0ec6d2e..70b941149 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/ChangesView/ChangesView.tsx @@ -6,13 +6,22 @@ import { HiMiniMinus, HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { useChangesStore } from "renderer/stores/changes"; import type { ChangeCategory, ChangedFile } from "shared/changes-types"; + import { CategorySection } from "./components/CategorySection"; import { ChangesHeader } from "./components/ChangesHeader"; import { CommitInput } from "./components/CommitInput"; import { CommitItem } from "./components/CommitItem"; import { FileList } from "./components/FileList"; -export function ChangesView() { +interface ChangesViewProps { + onFileOpen?: ( + file: ChangedFile, + category: ChangeCategory, + commitHash?: string, + ) => void; +} + +export function ChangesView({ onFileOpen }: ChangesViewProps) { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const worktreePath = activeWorkspace?.worktreePath; @@ -128,11 +137,13 @@ export function ChangesView() { const handleFileSelect = (file: ChangedFile, category: ChangeCategory) => { if (!worktreePath) return; selectFile(worktreePath, file, category, null); + onFileOpen?.(file, category); }; const handleCommitFileSelect = (file: ChangedFile, commitHash: string) => { if (!worktreePath) return; selectFile(worktreePath, file, "committed", commitHash); + onFileOpen?.(file, "committed", commitHash); }; const handleCommitToggle = (hash: string) => { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx index 2baa29305..06841519d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx @@ -17,7 +17,7 @@ import { trpc } from "renderer/lib/trpc"; import { usePresets } from "renderer/react-query/presets"; import { useOpenSettings, useSidebarStore } from "renderer/stores"; import { useTabsStore } from "renderer/stores/tabs/store"; -import { PortsList } from "./PortsList"; + import { PresetContextMenu } from "./PresetContextMenu"; import { TabItem } from "./TabItem"; import { TabsCommandDialog } from "./TabsCommandDialog"; @@ -277,7 +277,6 @@ export function TabsView() {
)}
- ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx index 751158761..a7baf0f71 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/index.tsx @@ -1,29 +1,46 @@ -import { useSidebarStore } from "renderer/stores"; -import { SidebarMode } from "renderer/stores/sidebar-state"; +import { trpc } from "renderer/lib/trpc"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; +import type { ChangeCategory, ChangedFile } from "shared/changes-types"; import { ChangesView } from "./ChangesView"; -import { ModeCarousel } from "./ModeCarousel"; -import { TabsView } from "./TabsView"; export function Sidebar() { - const { currentMode, setMode } = useSidebarStore(); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const workspaceId = activeWorkspace?.id; - const modes: SidebarMode[] = [SidebarMode.Tabs, SidebarMode.Changes]; + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + + const viewMode = workspaceId + ? (viewModeByWorkspaceId[workspaceId] ?? "workbench") + : "workbench"; + + const addFileViewerPane = useTabsStore((s) => s.addFileViewerPane); + + const handleFileOpen = + viewMode === "workbench" && workspaceId + ? (file: ChangedFile, category: ChangeCategory, commitHash?: string) => { + addFileViewerPane(workspaceId, { + filePath: file.path, + diffCategory: category, + commitHash, + oldPath: file.oldPath, + }); + } + : undefined; + + if (viewMode === "review") { + return ( + + ); + } return ( ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx deleted file mode 100644 index f641fde5a..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/WorkspaceActionBar.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { WorkspaceActionBarLeft } from "./components/WorkspaceActionBarLeft"; -import { WorkspaceActionBarRight } from "./components/WorkspaceActionBarRight"; - -interface WorkspaceActionBarProps { - worktreePath: string | undefined; -} - -export function WorkspaceActionBar({ worktreePath }: WorkspaceActionBarProps) { - if (!worktreePath) return null; - - return ( -
-
- -
-
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx deleted file mode 100644 index 0db773eb9..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/WorkspaceActionBarLeft.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { GoGitBranch } from "react-icons/go"; -import { trpc } from "renderer/lib/trpc"; - -export function WorkspaceActionBarLeft() { - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const currentBranch = activeWorkspace?.worktree?.branch; - const baseBranch = activeWorkspace?.worktree?.baseBranch; - return ( - <> - {currentBranch && ( - - - - - - {currentBranch} - - - - - Current branch - - - )} - {baseBranch && baseBranch !== currentBranch && ( - - - - from - {baseBranch} - - - - Based on {baseBranch} - - - )} - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts deleted file mode 100644 index 9a42acaa8..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarLeft/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceActionBarLeft } from "./WorkspaceActionBarLeft"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts deleted file mode 100644 index da70bada5..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/components/WorkspaceActionBarRight/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceActionBarRight } from "./WorkspaceActionBarRight"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts deleted file mode 100644 index c8caa3fbc..000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceActionBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WorkspaceActionBar } from "./WorkspaceActionBar"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index cdc375cd3..e7ef8e426 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -7,9 +7,9 @@ import { useHasWorkspaceFailed, useIsWorkspaceInitializing, } from "renderer/stores/workspace-init"; +import { useWorkspaceViewModeStore } from "renderer/stores/workspace-view-mode"; import { ContentView } from "./ContentView"; import { ResizableSidebar } from "./ResizableSidebar"; -import { WorkspaceActionBar } from "./WorkspaceActionBar"; import { WorkspaceInitializingView } from "./WorkspaceInitializingView"; export function WorkspaceView() { @@ -61,16 +61,31 @@ export function WorkspaceView() { // Get focused pane ID for the active tab const focusedPaneId = activeTabId ? focusedPaneIds[activeTabId] : null; + // View mode for terminal creation - subscribe to actual data for reactivity + const viewModeByWorkspaceId = useWorkspaceViewModeStore( + (s) => s.viewModeByWorkspaceId, + ); + const setWorkspaceViewMode = useWorkspaceViewModeStore( + (s) => s.setWorkspaceViewMode, + ); + const viewMode = activeWorkspaceId + ? (viewModeByWorkspaceId[activeWorkspaceId] ?? "workbench") + : "workbench"; + // Tab management shortcuts useAppHotkey( - "NEW_TERMINAL", + "NEW_GROUP", () => { if (activeWorkspaceId) { + // If in Review mode, switch to Workbench first + if (viewMode === "review") { + setWorkspaceViewMode(activeWorkspaceId, "workbench"); + } addTab(activeWorkspaceId); } }, undefined, - [activeWorkspaceId, addTab], + [activeWorkspaceId, addTab, viewMode, setWorkspaceViewMode], ); useAppHotkey( @@ -85,7 +100,7 @@ export function WorkspaceView() { [focusedPaneId, removePane], ); - // Switch between tabs (configurable shortcut) + // Switch between tabs (⌘+Up/Down) useAppHotkey( "PREV_TERMINAL", () => { @@ -112,7 +127,7 @@ export function WorkspaceView() { [activeWorkspaceId, activeTabId, tabs, setActiveTab], ); - // Switch between panes within a tab (configurable shortcut) + // Switch between panes within a tab (⌘+⌥+Left/Right) useAppHotkey( "PREV_PANE", () => { @@ -174,8 +189,7 @@ export function WorkspaceView() {
-
- +
{showInitView && activeWorkspaceId ? ( s.toggleSidebar); + const toggleWorkspaceSidebar = useWorkspaceSidebarStore((s) => s.toggleOpen); const hasTasksAccess = useFeatureFlagEnabled( FEATURE_FLAGS.ELECTRIC_TASKS_ACCESS, ); + const { data: activeWorkspace, isLoading: isWorkspaceLoading, @@ -126,6 +130,15 @@ export function MainScreen() { [toggleSidebar, isWorkspaceView], ); + useAppHotkey( + "TOGGLE_WORKSPACE_SIDEBAR", + () => { + toggleWorkspaceSidebar(); + }, + undefined, + [toggleWorkspaceSidebar], + ); + /** * Resolves the target pane for split operations. * If the focused pane is desynced from layout (e.g., was removed), @@ -352,7 +365,10 @@ export function MainScreen() { ) : (
-
{renderContent()}
+
+ + {renderContent()} +
)} diff --git a/apps/desktop/src/renderer/stores/index.ts b/apps/desktop/src/renderer/stores/index.ts index 8af7967ee..a94d077a0 100644 --- a/apps/desktop/src/renderer/stores/index.ts +++ b/apps/desktop/src/renderer/stores/index.ts @@ -7,3 +7,5 @@ export * from "./sidebar-state"; export * from "./tabs"; export * from "./theme"; export * from "./workspace-init"; +export * from "./workspace-sidebar-state"; +export * from "./workspace-view-mode"; diff --git a/apps/desktop/src/renderer/stores/new-workspace-modal.ts b/apps/desktop/src/renderer/stores/new-workspace-modal.ts index 0890c7797..38b18916b 100644 --- a/apps/desktop/src/renderer/stores/new-workspace-modal.ts +++ b/apps/desktop/src/renderer/stores/new-workspace-modal.ts @@ -3,7 +3,8 @@ import { devtools } from "zustand/middleware"; interface NewWorkspaceModalState { isOpen: boolean; - openModal: () => void; + preSelectedProjectId: string | null; + openModal: (projectId?: string) => void; closeModal: () => void; } @@ -11,13 +12,14 @@ export const useNewWorkspaceModalStore = create()( devtools( (set) => ({ isOpen: false, + preSelectedProjectId: null, - openModal: () => { - set({ isOpen: true }); + openModal: (projectId?: string) => { + set({ isOpen: true, preSelectedProjectId: projectId ?? null }); }, closeModal: () => { - set({ isOpen: false }); + set({ isOpen: false, preSelectedProjectId: null }); }, }), { name: "NewWorkspaceModalStore" }, @@ -31,3 +33,5 @@ export const useOpenNewWorkspaceModal = () => useNewWorkspaceModalStore((state) => state.openModal); export const useCloseNewWorkspaceModal = () => useNewWorkspaceModalStore((state) => state.closeModal); +export const usePreSelectedProjectId = () => + useNewWorkspaceModalStore((state) => state.preSelectedProjectId); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index d28d8316a..828d0679d 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -4,9 +4,10 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { trpcTabsStorage } from "../../lib/trpc-storage"; import { movePaneToNewTab, movePaneToTab } from "./actions/move-pane"; -import type { TabsState, TabsStore } from "./types"; +import type { AddFileViewerPaneOptions, TabsState, TabsStore } from "./types"; import { type CreatePaneOptions, + createFileViewerPane, createPane, createTabWithPane, extractPaneIdsFromLayout, @@ -125,7 +126,11 @@ export const useTabsStore = create()( const paneIds = getPaneIdsForTab(state.panes, tabId); for (const paneId of paneIds) { - killTerminalForPane(paneId); + // Only kill terminal sessions for terminal panes (avoids unnecessary IPC for file-viewers) + const pane = state.panes[paneId]; + if (pane?.type === "terminal") { + killTerminalForPane(paneId); + } } const newPanes = { ...state.panes }; @@ -285,7 +290,10 @@ export const useTabsStore = create()( const newPanes = { ...state.panes }; for (const paneId of removedPaneIds) { - killTerminalForPane(paneId); + // P2: Only kill terminal for actual terminal panes (avoid unnecessary IPC) + if (state.panes[paneId]?.type === "terminal") { + killTerminalForPane(paneId); + } delete newPanes[paneId]; } @@ -340,6 +348,112 @@ export const useTabsStore = create()( return newPane.id; }, + addFileViewerPane: ( + workspaceId: string, + options: AddFileViewerPaneOptions, + ) => { + const state = get(); + const activeTabId = state.activeTabIds[workspaceId]; + const activeTab = state.tabs.find((t) => t.id === activeTabId); + + // If no active tab, create a new one (this shouldn't normally happen) + if (!activeTab) { + const { tabId, paneId } = get().addTab(workspaceId); + // Update the pane to be a file-viewer (must use set() to get fresh state after addTab) + const fileViewerPane = createFileViewerPane(tabId, options); + set((s) => ({ + panes: { + ...s.panes, + [paneId]: { + ...fileViewerPane, + id: paneId, // Keep the original ID + }, + }, + })); + return paneId; + } + + // Look for an existing unlocked file-viewer pane in the active tab + const tabPaneIds = extractPaneIdsFromLayout(activeTab.layout); + const fileViewerPanes = tabPaneIds + .map((id) => state.panes[id]) + .filter( + (p) => + p?.type === "file-viewer" && + p.fileViewer && + !p.fileViewer.isLocked, + ); + + // If we found an unlocked file-viewer pane, reuse it + if (fileViewerPanes.length > 0) { + const paneToReuse = fileViewerPanes[0]; + const fileName = + options.filePath.split("/").pop() || options.filePath; + + // Determine default view mode + let viewMode: "raw" | "rendered" | "diff" = "raw"; + if (options.diffCategory) { + viewMode = "diff"; + } else if ( + options.filePath.endsWith(".md") || + options.filePath.endsWith(".markdown") || + options.filePath.endsWith(".mdx") + ) { + viewMode = "rendered"; + } + + set({ + panes: { + ...state.panes, + [paneToReuse.id]: { + ...paneToReuse, + name: fileName, + fileViewer: { + filePath: options.filePath, + viewMode, + isLocked: false, + diffLayout: "inline", + diffCategory: options.diffCategory, + commitHash: options.commitHash, + oldPath: options.oldPath, + initialLine: options.line, + initialColumn: options.column, + }, + }, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: paneToReuse.id, + }, + }); + + return paneToReuse.id; + } + + // No reusable pane found, create a new one + const newPane = createFileViewerPane(activeTab.id, options); + + const newLayout: MosaicNode = { + direction: "row", + first: activeTab.layout, + second: newPane.id, + splitPercentage: 50, + }; + + set({ + tabs: state.tabs.map((t) => + t.id === activeTab.id ? { ...t, layout: newLayout } : t, + ), + panes: { ...state.panes, [newPane.id]: newPane }, + focusedPaneIds: { + ...state.focusedPaneIds, + [activeTab.id]: newPane.id, + }, + }); + + return newPane.id; + }, + removePane: (paneId) => { const state = get(); const pane = state.panes[paneId]; @@ -354,7 +468,10 @@ export const useTabsStore = create()( return; } - killTerminalForPane(paneId); + // Only kill terminal sessions for terminal panes (avoids unnecessary IPC for file-viewers) + if (pane.type === "terminal") { + killTerminalForPane(paneId); + } const newLayout = removePaneFromLayout(tab.layout, paneId); if (!newLayout) { @@ -428,6 +545,33 @@ export const useTabsStore = create()( })); }, + clearWorkspaceAttention: (workspaceId) => { + const state = get(); + const workspaceTabs = state.tabs.filter( + (t) => t.workspaceId === workspaceId, + ); + const workspacePaneIds = workspaceTabs.flatMap((t) => + extractPaneIdsFromLayout(t.layout), + ); + + if (workspacePaneIds.length === 0) { + return; + } + + const newPanes = { ...state.panes }; + let hasChanges = false; + for (const paneId of workspacePaneIds) { + if (newPanes[paneId]?.needsAttention) { + newPanes[paneId] = { ...newPanes[paneId], needsAttention: false }; + hasChanges = true; + } + } + + if (hasChanges) { + set({ panes: newPanes }); + } + }, + updatePaneCwd: (paneId, cwd, confirmed) => { set((state) => ({ panes: { @@ -463,7 +607,19 @@ export const useTabsStore = create()( const sourcePane = state.panes[sourcePaneId]; if (!sourcePane || sourcePane.tabId !== tabId) return; - const newPane = createPane(tabId); + // Clone file-viewer panes instead of creating a terminal + const newPane = + sourcePane.type === "file-viewer" && sourcePane.fileViewer + ? createFileViewerPane(tabId, { + filePath: sourcePane.fileViewer.filePath, + viewMode: sourcePane.fileViewer.viewMode, + isLocked: true, // Lock the cloned pane + diffLayout: sourcePane.fileViewer.diffLayout, + diffCategory: sourcePane.fileViewer.diffCategory, + commitHash: sourcePane.fileViewer.commitHash, + oldPath: sourcePane.fileViewer.oldPath, + }) + : createPane(tabId); let newLayout: MosaicNode; if (path && path.length > 0) { @@ -511,7 +667,19 @@ export const useTabsStore = create()( const sourcePane = state.panes[sourcePaneId]; if (!sourcePane || sourcePane.tabId !== tabId) return; - const newPane = createPane(tabId); + // Clone file-viewer panes instead of creating a terminal + const newPane = + sourcePane.type === "file-viewer" && sourcePane.fileViewer + ? createFileViewerPane(tabId, { + filePath: sourcePane.fileViewer.filePath, + viewMode: sourcePane.fileViewer.viewMode, + isLocked: true, // Lock the cloned pane + diffLayout: sourcePane.fileViewer.diffLayout, + diffCategory: sourcePane.fileViewer.diffCategory, + commitHash: sourcePane.fileViewer.commitHash, + oldPath: sourcePane.fileViewer.oldPath, + }) + : createPane(tabId); let newLayout: MosaicNode; if (path && path.length > 0) { diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index bcb0f70af..9638df6e0 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -1,4 +1,5 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import type { ChangeCategory } from "shared/changes-types"; import type { BaseTab, BaseTabsState, Pane, PaneType } from "shared/tabs-types"; // Re-export shared types @@ -28,6 +29,20 @@ export interface AddTabOptions { initialCwd?: string; } +/** + * Options for opening a file in a file-viewer pane + */ +export interface AddFileViewerPaneOptions { + filePath: string; + diffCategory?: ChangeCategory; + commitHash?: string; + oldPath?: string; + /** Line to scroll to (raw mode only) */ + line?: number; + /** Column to scroll to (raw mode only) */ + column?: number; +} + /** * Actions available on the tabs store */ @@ -51,10 +66,15 @@ export interface TabsStore extends TabsState { // Pane operations addPane: (tabId: string, options?: AddTabOptions) => string; + addFileViewerPane: ( + workspaceId: string, + options: AddFileViewerPaneOptions, + ) => string; removePane: (paneId: string) => void; setFocusedPane: (tabId: string, paneId: string) => void; markPaneAsUsed: (paneId: string) => void; setNeedsAttention: (paneId: string, needsAttention: boolean) => void; + clearWorkspaceAttention: (workspaceId: string) => void; updatePaneCwd: ( paneId: string, cwd: string | null, diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index a1e7bef16..62ee90aad 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1,6 +1,14 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import type { ChangeCategory } from "shared/changes-types"; +import type { + DiffLayout, + FileViewerMode, + FileViewerState, +} from "shared/tabs-types"; import type { Pane, PaneType, Tab } from "./types"; +const MARKDOWN_EXTENSIONS = [".md", ".markdown", ".mdx"] as const; + /** * Generates a unique ID with the given prefix */ @@ -82,6 +90,66 @@ export const createPane = ( }; }; +/** + * Options for creating a file-viewer pane + */ +export interface CreateFileViewerPaneOptions { + filePath: string; + viewMode?: FileViewerMode; + isLocked?: boolean; + diffLayout?: DiffLayout; + diffCategory?: ChangeCategory; + commitHash?: string; + oldPath?: string; + /** Line to scroll to (raw mode only) */ + line?: number; + /** Column to scroll to (raw mode only) */ + column?: number; +} + +/** + * Creates a new file-viewer pane with the given properties + */ +export const createFileViewerPane = ( + tabId: string, + options: CreateFileViewerPaneOptions, +): Pane => { + const id = generateId("pane"); + + // Determine default view mode based on file and category + let defaultViewMode: FileViewerMode = "raw"; + if (options.diffCategory) { + defaultViewMode = "diff"; + } else if ( + MARKDOWN_EXTENSIONS.some((ext) => options.filePath.endsWith(ext)) + ) { + defaultViewMode = "rendered"; + } + + const fileViewer: FileViewerState = { + filePath: options.filePath, + viewMode: options.viewMode ?? defaultViewMode, + isLocked: options.isLocked ?? false, + diffLayout: options.diffLayout ?? "inline", + diffCategory: options.diffCategory, + commitHash: options.commitHash, + oldPath: options.oldPath, + initialLine: options.line, + initialColumn: options.column, + }; + + // Use filename for display name + const fileName = options.filePath.split("/").pop() || options.filePath; + + return { + id, + tabId, + type: "file-viewer", + name: fileName, + fileViewer, + }; +}; + /** * Generates a static tab name based on existing tabs * (e.g., "Terminal 1", "Terminal 2", finding the next available number) 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 000000000..adb12801e --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-sidebar-state.ts @@ -0,0 +1,105 @@ +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, + // Exclude ephemeral state from persistence + partialize: (state) => ({ + isOpen: state.isOpen, + width: state.width, + lastOpenWidth: state.lastOpenWidth, + collapsedProjectIds: state.collapsedProjectIds, + // isResizing intentionally excluded - ephemeral UI state + }), + }, + ), + { name: "WorkspaceSidebarStore" }, + ), +); diff --git a/apps/desktop/src/renderer/stores/workspace-view-mode.ts b/apps/desktop/src/renderer/stores/workspace-view-mode.ts new file mode 100644 index 000000000..b9bee9b26 --- /dev/null +++ b/apps/desktop/src/renderer/stores/workspace-view-mode.ts @@ -0,0 +1,56 @@ +import { create } from "zustand"; +import { devtools, persist } from "zustand/middleware"; + +/** + * Workspace view modes: + * - "workbench": Groups + Mosaic panes layout for in-flow work + * - "review": Dedicated Changes page for focused code review + */ +export type WorkspaceViewMode = "workbench" | "review"; + +interface WorkspaceViewModeState { + /** + * Per-workspace view mode. Defaults to "workbench" when not set. + */ + viewModeByWorkspaceId: Record; + + /** + * Get the view mode for a workspace, defaulting to "workbench" + */ + getWorkspaceViewMode: (workspaceId: string) => WorkspaceViewMode; + + /** + * Set the view mode for a workspace + */ + setWorkspaceViewMode: (workspaceId: string, mode: WorkspaceViewMode) => void; +} + +export const useWorkspaceViewModeStore = create()( + devtools( + persist( + (set, get) => ({ + viewModeByWorkspaceId: {}, + + getWorkspaceViewMode: (workspaceId: string) => { + return get().viewModeByWorkspaceId[workspaceId] ?? "workbench"; + }, + + setWorkspaceViewMode: ( + workspaceId: string, + mode: WorkspaceViewMode, + ) => { + set((state) => ({ + viewModeByWorkspaceId: { + ...state.viewModeByWorkspaceId, + [workspaceId]: mode, + }, + })); + }, + }), + { + name: "workspace-view-mode-store", + }, + ), + { name: "WorkspaceViewModeStore" }, + ), +); diff --git a/apps/desktop/src/shared/constants.ts b/apps/desktop/src/shared/constants.ts index 6bc788cea..1ec904ae1 100644 --- a/apps/desktop/src/shared/constants.ts +++ b/apps/desktop/src/shared/constants.ts @@ -46,3 +46,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; diff --git a/apps/desktop/src/shared/hotkeys.ts b/apps/desktop/src/shared/hotkeys.ts index c4e38007b..2a2b46207 100644 --- a/apps/desktop/src/shared/hotkeys.ts +++ b/apps/desktop/src/shared/hotkeys.ts @@ -423,7 +423,12 @@ export const HOTKEYS = { // Layout TOGGLE_SIDEBAR: defineHotkey({ keys: "meta+b", - label: "Toggle Sidebar", + label: "Toggle Files Sidebar", + category: "Layout", + }), + TOGGLE_WORKSPACE_SIDEBAR: defineHotkey({ + keys: "meta+shift+b", + label: "Toggle Workspaces Sidebar", category: "Layout", }), SPLIT_RIGHT: defineHotkey({ @@ -452,9 +457,9 @@ export const HOTKEYS = { category: "Terminal", description: "Search text in the active terminal", }), - NEW_TERMINAL: defineHotkey({ + NEW_GROUP: defineHotkey({ keys: "meta+t", - label: "New Terminal", + label: "New Group", category: "Terminal", }), CLOSE_TERMINAL: defineHotkey({ @@ -575,6 +580,15 @@ export function getDefaultHotkey( return HOTKEYS[id].defaults[platform]; } +/** + * Get the hotkey binding for the current platform. + * Convenience wrapper around getDefaultHotkey. + * Returns empty string if no hotkey is defined (safe for useHotkeys). + */ +export function getHotkey(id: HotkeyId): string { + return getDefaultHotkey(id, getCurrentPlatform()) ?? ""; +} + export function getEffectiveHotkey( id: HotkeyId, overrides: Partial>, diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index 8ae323601..d8c921186 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -3,10 +3,46 @@ * Renderer extends these with MosaicNode layout specifics. */ +import type { ChangeCategory } from "./changes-types"; + /** * Pane types that can be displayed within a tab */ -export type PaneType = "terminal" | "webview"; +export type PaneType = "terminal" | "webview" | "file-viewer"; + +/** + * File viewer display modes + */ +export type FileViewerMode = "rendered" | "raw" | "diff"; + +/** + * Diff layout options for file viewer + */ +export type DiffLayout = "inline" | "side-by-side"; + +/** + * File viewer pane-specific properties + */ +export interface FileViewerState { + /** Worktree-relative file path */ + filePath: string; + /** Display mode: rendered (markdown), raw (source), or diff */ + viewMode: FileViewerMode; + /** If true, this pane won't be reused for new file clicks */ + isLocked: boolean; + /** Diff display layout */ + diffLayout: DiffLayout; + /** Category for diff source (against-main, committed, staged, unstaged) */ + diffCategory?: ChangeCategory; + /** Commit hash for committed category diffs */ + commitHash?: string; + /** Original path for renamed files */ + oldPath?: string; + /** Initial line to scroll to (raw mode only, transient - applied once) */ + initialLine?: number; + /** Initial column to scroll to (raw mode only, transient - applied once) */ + initialColumn?: number; +} /** * Base Pane interface - shared between main and renderer @@ -23,6 +59,7 @@ export interface Pane { url?: string; // For webview panes cwd?: string | null; // Current working directory cwdConfirmed?: boolean; // True if cwd confirmed via OSC-7, false if seeded + fileViewer?: FileViewerState; // For file-viewer panes } /** diff --git a/bun.lock b/bun.lock index 5e16802e3..cbb3e7cbc 100644 --- a/bun.lock +++ b/bun.lock @@ -190,7 +190,6 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^19.2.3", - "react-hotkeys-hook": "^5.2.1", "react-icons": "^5.5.0", "react-markdown": "^10.1.0", "react-mosaic-component": "^6.1.1", @@ -3030,8 +3029,6 @@ "react-hook-form": ["react-hook-form@7.69.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw=="], - "react-hotkeys-hook": ["react-hotkeys-hook@5.2.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xbKh6zJxd/vJHT4Bw4+0pBD662Fk20V+VFhLqciCg+manTVO4qlqRqiwFOYelfHN9dBvWj9vxaPkSS26ZSIJGg=="], - "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], diff --git a/packages/local-db/drizzle/0004_add_terminal_link_behavior_setting.sql b/packages/local-db/drizzle/0004_add_terminal_link_behavior_setting.sql new file mode 100644 index 000000000..ad70f21f3 --- /dev/null +++ b/packages/local-db/drizzle/0004_add_terminal_link_behavior_setting.sql @@ -0,0 +1 @@ +ALTER TABLE `settings` ADD `terminal_link_behavior` text; \ No newline at end of file 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 000000000..c3c175a03 --- /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/0006_add_unique_branch_workspace_index.sql b/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql new file mode 100644 index 000000000..ac308f5f3 --- /dev/null +++ b/packages/local-db/drizzle/0006_add_unique_branch_workspace_index.sql @@ -0,0 +1,47 @@ +-- Dedupe existing duplicate branch workspaces before creating unique index. +-- Keep the most recently used one (highest last_opened_at), with id ASC as tiebreaker. +-- First, update settings.last_active_workspace_id if it points to a workspace we're about to delete +UPDATE settings +SET last_active_workspace_id = ( + SELECT w1.id FROM workspaces w1 + WHERE w1.type = 'branch' + AND w1.project_id = ( + SELECT w2.project_id FROM workspaces w2 WHERE w2.id = settings.last_active_workspace_id + ) + ORDER BY w1.last_opened_at DESC NULLS LAST, w1.id ASC + LIMIT 1 +) +WHERE last_active_workspace_id IN ( + SELECT w1.id FROM workspaces w1 + WHERE w1.type = 'branch' + AND EXISTS ( + SELECT 1 FROM workspaces w2 + WHERE w2.type = 'branch' + AND w2.project_id = w1.project_id + AND ( + w2.last_opened_at > w1.last_opened_at + OR (w2.last_opened_at = w1.last_opened_at AND w2.id < w1.id) + OR (w2.last_opened_at IS NOT NULL AND w1.last_opened_at IS NULL) + OR (w1.last_opened_at IS NULL AND w2.last_opened_at IS NULL AND w2.id < w1.id) + ) + ) +); +--> statement-breakpoint +-- Delete duplicate branch workspaces, keeping the most recently used per project +-- Survivor selection: highest last_opened_at, then lowest id as tiebreaker +DELETE FROM workspaces +WHERE type = 'branch' +AND id NOT IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY project_id + ORDER BY last_opened_at DESC NULLS LAST, id ASC + ) as rn + FROM workspaces + WHERE type = 'branch' + ) ranked + WHERE rn = 1 +); +--> statement-breakpoint +-- Now safe to create the unique index +CREATE UNIQUE INDEX IF NOT EXISTS `workspaces_unique_branch_per_project` ON `workspaces` (`project_id`) WHERE `type` = 'branch'; diff --git a/packages/local-db/drizzle/0007_add_workspace_is_unread.sql b/packages/local-db/drizzle/0007_add_workspace_is_unread.sql new file mode 100644 index 000000000..9f3ca8ec3 --- /dev/null +++ b/packages/local-db/drizzle/0007_add_workspace_is_unread.sql @@ -0,0 +1 @@ +ALTER TABLE `workspaces` ADD `is_unread` integer DEFAULT false; diff --git a/packages/local-db/drizzle/0008_add_group_tabs_position.sql b/packages/local-db/drizzle/0008_add_group_tabs_position.sql new file mode 100644 index 000000000..2be007e2a --- /dev/null +++ b/packages/local-db/drizzle/0008_add_group_tabs_position.sql @@ -0,0 +1 @@ +ALTER TABLE `settings` ADD `group_tabs_position` text; diff --git a/packages/local-db/drizzle/meta/0004_snapshot.json b/packages/local-db/drizzle/meta/0004_snapshot.json new file mode 100644 index 000000000..991b5469e --- /dev/null +++ b/packages/local-db/drizzle/meta/0004_snapshot.json @@ -0,0 +1,977 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bb9f9f85-bcbb-4003-b20f-4c172a1c6fc8", + "prevId": "d5a52ac9-bc1e-4529-89bf-5748d4df5006", + "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 + } + }, + "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/0005_snapshot.json b/packages/local-db/drizzle/meta/0005_snapshot.json new file mode 100644 index 000000000..14c02c328 --- /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/0006_snapshot.json b/packages/local-db/drizzle/meta/0006_snapshot.json new file mode 100644 index 000000000..5362480f6 --- /dev/null +++ b/packages/local-db/drizzle/meta/0006_snapshot.json @@ -0,0 +1,984 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "prevId": "ac200b80-657f-4cd7-b338-2d6adeb925e7", + "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": {} + } +} diff --git a/packages/local-db/drizzle/meta/0007_snapshot.json b/packages/local-db/drizzle/meta/0007_snapshot.json new file mode 100644 index 000000000..dbf24a697 --- /dev/null +++ b/packages/local-db/drizzle/meta/0007_snapshot.json @@ -0,0 +1,992 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a7b8c9d0-e1f2-3456-7890-abcdef123456", + "prevId": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "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 + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 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": {} + } +} diff --git a/packages/local-db/drizzle/meta/0008_snapshot.json b/packages/local-db/drizzle/meta/0008_snapshot.json new file mode 100644 index 000000000..3c3664f7a --- /dev/null +++ b/packages/local-db/drizzle/meta/0008_snapshot.json @@ -0,0 +1,999 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "160d23b9-a426-4e93-866a-3d6e6fc3c704", + "prevId": "a7b8c9d0-e1f2-3456-7890-abcdef123456", + "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 + }, + "group_tabs_position": { + "name": "group_tabs_position", + "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 + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 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 3117a6e22..3b06ca4a7 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -29,6 +29,41 @@ "when": 1766932805546, "tag": "0003_add_confirm_on_quit_setting", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1767166138761, + "tag": "0004_add_terminal_link_behavior_setting", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1767166547886, + "tag": "0005_add_navigation_style", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1767230000000, + "tag": "0006_add_unique_branch_workspace_index", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1767350000000, + "tag": "0007_add_workspace_is_unread", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1767548339097, + "tag": "0008_add_group_tabs_position", + "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 9b0639805..4da25f609 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -5,6 +5,7 @@ import type { ExternalApp, GitHubStatus, GitStatus, + TerminalLinkBehavior, TerminalPreset, WorkspaceType, } from "./zod"; @@ -100,20 +101,24 @@ export const workspaces = sqliteTable( lastOpenedAt: integer("last_opened_at") .notNull() .$defaultFn(() => Date.now()), + isUnread: integer("is_unread", { mode: "boolean" }).default(false), }, (table) => [ index("workspaces_project_id_idx").on(table.projectId), index("workspaces_worktree_id_idx").on(table.worktreeId), index("workspaces_last_opened_at_idx").on(table.lastOpenedAt), + // NOTE: Migration 0006 creates an additional partial unique index: + // CREATE UNIQUE INDEX workspaces_unique_branch_per_project + // ON workspaces(project_id) WHERE type = 'branch' + // This enforces one branch workspace per project. Drizzle's schema DSL + // doesn't support partial/filtered indexes, so this constraint is only + // applied via the migration, not schema push. See migration 0006 for details. ], ); export type InsertWorkspace = typeof workspaces.$inferInsert; export type SelectWorkspace = typeof workspaces.$inferSelect; -/** - * Settings table - single row with typed columns - */ export const settings = sqliteTable("settings", { id: integer("id").primaryKey().default(1), lastActiveWorkspaceId: text("last_active_workspace_id"), @@ -127,6 +132,9 @@ export const settings = sqliteTable("settings", { selectedRingtoneId: text("selected_ringtone_id"), activeOrganizationId: text("active_organization_id"), confirmOnQuit: integer("confirm_on_quit", { mode: "boolean" }), + terminalLinkBehavior: text( + "terminal_link_behavior", + ).$type(), }); export type InsertSettings = typeof settings.$inferInsert; diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts index bb33e1d15..ca8221e40 100644 --- a/packages/local-db/src/schema/zod.ts +++ b/packages/local-db/src/schema/zod.ts @@ -96,3 +96,13 @@ export const EXTERNAL_APPS = [ ] as const; export type ExternalApp = (typeof EXTERNAL_APPS)[number]; + +/** + * Terminal link behavior options + */ +export const TERMINAL_LINK_BEHAVIORS = [ + "external-editor", + "file-viewer", +] as const; + +export type TerminalLinkBehavior = (typeof TERMINAL_LINK_BEHAVIORS)[number];