From fb5166ed3ae4c1338dfb22c20c5fb19712788105 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 04:59:27 +0000 Subject: [PATCH 01/50] docs: add framework-agnostic UI inventory for a future rewrite Document every 2code window, screen, region, dialog, and layout contract so a later UI-framework rewrite can rebuild the product without reading the current React stack. Co-authored-by: AkaraChen --- docs/README.md | 4 + docs/sidebar-ui-inventory.md | 1073 ++++++++++++++ docs/ui-inventory-home-project.md | 948 +++++++++++++ ...ory-settings-terminal-git-debug-updater.md | 1228 +++++++++++++++++ docs/ui-inventory.md | 977 +++++++++++++ 5 files changed, 4230 insertions(+) create mode 100644 docs/sidebar-ui-inventory.md create mode 100644 docs/ui-inventory-home-project.md create mode 100644 docs/ui-inventory-settings-terminal-git-debug-updater.md create mode 100644 docs/ui-inventory.md diff --git a/docs/README.md b/docs/README.md index 2dc9ea3f..5e703b6c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -70,3 +70,7 @@ | [API Reference](api-reference.md) | All Tauri commands, Tauri events, HTTP endpoints | | [Configuration](configuration.md) | Config files, environment variables, database schema | | [Notification Behavior](notification-behavior.md) | Terminal unread-dot state machine and click behavior | +| [UI Inventory](ui-inventory.md) | Framework-agnostic rewrite spec for every window, screen, component, layout, and dialog | +| [Sidebar UI Inventory](sidebar-ui-inventory.md) | Pixel-level appendix: app sidebar + profile sidebar | +| [Home / Project UI Inventory](ui-inventory-home-project.md) | Pixel-level appendix: home, file tree, viewer, command palette, project dialogs | +| [Settings / Terminal / Git UI Inventory](ui-inventory-settings-terminal-git-debug-updater.md) | Pixel-level appendix: settings window, terminal, git, debug, updater | diff --git a/docs/sidebar-ui-inventory.md b/docs/sidebar-ui-inventory.md new file mode 100644 index 00000000..1ebf9f4e --- /dev/null +++ b/docs/sidebar-ui-inventory.md @@ -0,0 +1,1073 @@ +# 2code Sidebar / Left-Nav UI Inventory + +Framework-agnostic inventory of every visual and interactive element in the app’s left navigation surfaces. Derived exclusively from source files listed in the exploration scope; nothing below is inferred beyond what the code defines. + +**Scope:** Primary app sidebar (`AppSidebar`), profile secondary sidebar (`ProfileSidebar`), related primitives, loading/error fallbacks, window chrome that affects sidebar layout, and the top-bar mode switch that controls the profile sidebar. + +**Last audited against:** `src/layout/*`, `src/features/projects/ProfileSidebar.tsx`, `src/features/projects/SidebarModeSwitch.tsx`, `src/features/git/components/SidebarGitPanel.tsx`, `src/components/ui/sidebar.tsx`, and supporting shared components. + +--- + +## 1. Application layout context + +``` +App (flex h-full flex-col) +└── div (flex min-h-0 flex-1) ← horizontal split + ├── AsyncBoundary + │ └── AppSidebar OR SidebarSkeleton OR SidebarError + └── main (relative flex-1 overflow-y-auto bg-card) + ├── Routes (HomePage, ProjectDetailPage, …) + └── TerminalLayer (persistent overlay) + +ProjectDetailPage → ProfileLayout +└── div (flex h-full flex-col) + ├── CommandPalette + ├── div.border-b → ProjectTopBar (contains SidebarModeSwitch + expand-sidebar when collapsed) + └── div (flex min-h-0 min-w-0 flex-1) + ├── ProfileSidebar (files | git | notes) + └── div.min-h-0.flex-1 → main content (terminals, file viewer, …) + +Windows only: WindowControls (fixed top-right, h-7) +``` + +When `useAppSidebarStore.isCollapsed === true`, `AppSidebar` renders **nothing** (`return null`). The expand control appears in `ProjectTopBar` (project routes only), not on `HomePage`. + +--- + +## 2. Design tokens and numeric constants + +### 2.1 App sidebar width (`sidebarStore.ts`) + +| Constant | Value | Usage | +|----------|-------|--------| +| `APP_SIDEBAR_MIN_WIDTH` | **220** px | Resize separator `aria-valuemin`, clamp | +| `APP_SIDEBAR_MAX_WIDTH` | **420** px | Resize separator `aria-valuemax`, clamp | +| `APP_SIDEBAR_DEFAULT_WIDTH` | **250** px | Initial persisted width | +| Resize keyboard step | **16** px | `useHorizontalResize` default `step` | + +CSS variable `--sidebar-width` is set on: +- `SidebarProvider` inline style in `AppSidebar` +- `document.documentElement` via `sidebarStore` subscribe/sync + +Default in `app.css`: `--sidebar-width: 250px` + +### 2.2 Profile sidebar panel width (`ProfileSidebar.tsx`) + +| Constant | Value | +|----------|-------| +| `SIDEBAR_PANEL_MIN_WIDTH` | **180** px | +| `SIDEBAR_PANEL_MAX_WIDTH` | **560** px | +| `DEFAULT_SIDEBAR_PANEL_WIDTH` | **208** px | +| localStorage key | `"file-tree-panel"` | +| Resize keyboard step | **16** px | + +### 2.3 shadcn sidebar primitives (`sidebar.tsx`) — used but partially overridden + +| Token | Value | Notes | +|-------|-------|-------| +| `SIDEBAR_WIDTH` | `16rem` (**256** px at 16px root) | Overridden by AppSidebar dynamic px | +| `SIDEBAR_WIDTH_MOBILE` | `18rem` (**288** px) | Not used: AppSidebar sets `collapsible="none"` | +| `SIDEBAR_WIDTH_ICON` | `3rem` (**48** px) | Not used in AppSidebar | +| `SIDEBAR_KEYBOARD_SHORTCUT` | `"b"` | Cmd/Ctrl+B in `SidebarProvider` — AppSidebar does not use shadcn collapse | + +### 2.4 Sidebar color tokens (`app.css`) + +Light `:root`: +- `--sidebar`: oklch(0.985 0 0) +- `--sidebar-foreground`: oklch(0.145 0 0) +- `--sidebar-accent`: oklch(0.97 0 0) +- `--sidebar-accent-foreground`: oklch(0.205 0 0) +- `--sidebar-border`: oklch(0.922 0 0) +- `--sidebar-ring`: oklch(0.708 0 0) + +Dark `.dark` overrides exist for the same keys. + +### 2.5 Tailwind size reference (used in sidebar UI) + +| Class | Computed (16px root) | +|-------|----------------------| +| `size-4` / `[&_svg]:size-4` | 16×16 px | +| `size-3.5` | 14×14 px | +| `size-2` | 8×8 px (agent dot) | +| `size-6` | 24×24 px (maximize button) | +| `h-7` | 28 px | +| `h-8` | 32 px | +| `h-12` | 48 px (header brand button) | +| `w-5` / `min-w-5` / `h-5` | 20 px (badges, group actions) | +| `w-2` | 8 px (resize hit area) | +| `w-[250px]` | 250 px (skeleton/error fallback) | +| `pt-8` | 32 px (macOS header) | +| `pt-2` | 8 px (non-macOS header) | +| `p-2` | 8 px (header/footer/group padding) | +| `px-2` / `py-1.5` | horizontal 8 / vertical 6 px | +| `mx-3` | horizontal margin 12 px | +| `pl-4` | padding-left 16 px (reorder nested projects) | +| `right-9` | right offset 36 px (edit-order action) | +| `right-[-4px]` | extends resize handle 4 px past edge | +| `text-xs` | 12 px font | +| `text-sm` | 14 px font | +| `text-[0.625rem]` | 10 px (avatar fallback letter) | +| `rounded-md` | `--radius` related (~10 px with 0.625rem radius) | + +--- + +## 3. Shared UI primitives (`components/ui/sidebar.tsx`) + +App sidebar composes these building blocks. `AppSidebar` passes `collapsible="none"`, so only the **non-collapsible** code path runs (fixed-width column, no Sheet/mobile/offcanvas). + +### 3.1 `SidebarProvider` + +- Wrapper: `div.group/sidebar-wrapper.flex.min-h-svh.w-full` +- Merges CSS vars: `--sidebar-width`, `--sidebar-width-icon` +- AppSidebar adds: `className="h-full min-h-0 w-auto shrink-0"` and inline `--sidebar-width: ${sidebarWidth}px` + +### 3.2 `Sidebar` (collapsible="none" branch) + +- `div[data-slot=sidebar]` +- Classes: `flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground` + AppSidebar: `relative min-h-0 shrink-0 border-r` +- AppSidebar adds: `role="navigation"`, `aria-label={m.sideNavLabel()}`, `onKeyDown={handleKeyDown}` + +### 3.3 `SidebarHeader` + +- `div[data-slot=sidebar-header]` +- Default: `flex flex-col gap-2 p-2` +- AppSidebar: `shrink-0`, `data-tauri-drag-region`, platform padding (see §10) + +### 3.4 `SidebarContent` + +- `div[data-slot=sidebar-content]` +- Default: `no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto …` +- AppSidebar: `overflow-x-hidden [scrollbar-gutter:stable]` + +### 3.5 `SidebarFooter` + +- `div[data-slot=sidebar-footer]` +- Default: `flex flex-col gap-2 p-2` +- AppSidebar: `shrink-0` + +### 3.6 `SidebarGroup` + +- `div[data-slot=sidebar-group]` +- `relative flex w-full min-w-0 flex-col p-2` + +### 3.7 `SidebarGroupLabel` + +- `div` with: `flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 …` +- Contains section title text + optional `SidebarGroupAction` buttons (absolutely positioned) + +### 3.8 `SidebarGroupAction` + +- Button: `absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 … [&>svg]:size-4` +- Reorder-mode “done” variant adds: `right-9 bg-sidebar-accent text-sidebar-accent-foreground` +- Normal edit-order: `right-9` only on pencil button; plus button at default `right-3` + +### 3.9 `SidebarGroupContent` + +- `div`: `w-full text-sm` + +### 3.10 `SidebarMenu` / `SidebarMenuItem` + +- Menu: `ul.flex.w-full.min-w-0.flex-col.gap-0` +- Item: `li.group/menu-item.relative` + +### 3.11 `SidebarMenuButton` (variants) + +Base (`sidebarMenuButtonVariants`): +- `peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm …` +- `group-has-data-[sidebar=menu-action]/menu-item:pr-8` — reserves space for trailing action +- `[&_svg]:size-4 [&_svg]:shrink-0` +- `[&>span:last-child]:truncate` +- **Hover:** `hover:bg-sidebar-accent hover:text-sidebar-accent-foreground` +- **Active:** `data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground` +- **Focus:** `focus-visible:ring-2` on `ring-sidebar-ring` + +Sizes: +- `default`: `h-8 text-sm` +- `sm`: `h-7 text-xs` +- `lg`: `h-12 text-sm` (header brand) + +### 3.12 `SidebarMenuAction` + +- `absolute top-1.5 right-1 flex aspect-square w-5 … [&>svg]:size-4` +- **Hover:** `hover:bg-sidebar-accent hover:text-sidebar-accent-foreground` +- `showOnHover` mode (not used in AppSidebar project items): opacity 0 until group hover + +### 3.13 `SidebarMenuBadge` + +- `absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums` +- Used for project-group project counts + +### 3.14 `SidebarMenuSub` + +- Default: `ul.mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5` +- `ProjectGroupSection` override: `mx-0 translate-x-0 gap-0 border-l-0 px-0 py-0` + +### 3.15 `SidebarMenuSubItem` + +- `li.group/menu-sub-item.relative` + +### 3.16 `SidebarMenuSubButton` + +- `flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sm …` +- `[&>svg]:size-4`, active/hover same accent pattern as menu button + +--- + +## 4. Primary app sidebar — `AppSidebar.tsx` + +### 4.1 Visibility and collapse + +- **Fully hidden** when `isCollapsed === true` (entire component returns `null`). +- Collapse toggle: header `SidebarMenuAction` with `SidebarSimpleIcon`, `aria-label={m.collapseSidebar()}`, `aria-expanded={true}`, `onClick={toggleCollapsed}`. +- Persisted in localStorage key `"app-sidebar-width"` (partialized: `width`, `isCollapsed`, `collapsedProjectGroupIds`; `isReorderMode` is **not** persisted). + +### 4.2 Component tree (normal mode, projects exist) + +``` +AppSidebar +├── SidebarProvider (--sidebar-width: Npx) +│ └── Sidebar [role=navigation, aria-label=sideNavLabel] +│ ├── LayoutGroup id="app-sidebar" +│ │ ├── SidebarHeader [data-tauri-drag-region] +│ │ │ └── SidebarMenu +│ │ │ └── SidebarMenuItem +│ │ │ ├── SidebarMenuButton size=lg pointer-events-none +│ │ │ │ └── span.font-semibold → "2Code" +│ │ │ └── SidebarMenuAction → SidebarSimpleIcon (collapse) +│ │ ├── SidebarContent +│ │ │ ├── [optional] SidebarGroup "Pinned" +│ │ │ │ ├── SidebarGroupLabel → pinnedProjects +│ │ │ │ └── SidebarGroupContent → SidebarMenu +│ │ │ │ └── ProjectMenuItem × N +│ │ │ └── SidebarGroup "Projects" +│ │ │ ├── SidebarGroupLabel +│ │ │ │ ├── span → sidebarProjectsSection +│ │ │ │ ├── SidebarGroupAction → PencilSimpleLineIcon (edit order) +│ │ │ │ └── SidebarGroupAction id=add-project-button → PlusIcon +│ │ │ └── SidebarGroupContent → SidebarMenu +│ │ │ ├── ProjectGroupSection × groups +│ │ │ └── ProjectMenuItem × top-level projects +│ │ └── SidebarFooter +│ │ └── SidebarMenu +│ │ └── SidebarMenuItem +│ │ └── SidebarMenuButton → GearSixIcon + settings +│ └── div[role=separator] (width resize handle) +└── CreateProjectDialog +``` + +### 4.3 Header row + +| Element | Content | Layout / classes | Interaction | +|---------|---------|------------------|-------------| +| Brand button | Text **"2Code"** (hardcoded, not i18n) | `SidebarMenuButton size="lg" className="pointer-events-none"`, inner `span.font-semibold` | Non-interactive (`pointer-events-none`) | +| Collapse action | `SidebarSimpleIcon` weight `regular` | `SidebarMenuAction`, default action positioning | Click → `toggleCollapsed`; a11y: `collapseSidebar` | + +Both sit in one `SidebarMenuItem`. Menu button height **48 px** (`lg`). + +### 4.4 Empty projects state + +When `projects.length === 0`: + +``` +SidebarGroup +└── SidebarGroupContent + └── SidebarMenu + └── SidebarLink to="/" icon=HouseIcon → home +``` + +No pinned/projects sections, no footer change (footer still shows Settings). + +### 4.5 Pinned projects section + +**Visible when:** `sidebarLayout.pinnedProjects.length > 0` (normal mode). + +| Element | Content | +|---------|---------| +| `SidebarGroupLabel` | `m.pinnedProjects()` → **"Pinned"** | +| Rows | `ProjectMenuItem` per pinned project | + +### 4.6 Projects section (normal mode) + +| Element | Content | a11y | +|---------|---------|------| +| Label text | `m.sidebarProjectsSection()` → **"Projects"** | — | +| Edit order | `PencilSimpleLineIcon`, `className="right-9"` | `aria-label={editProjectOrder}`, `aria-pressed={isReorderMode}`, `disabled={isSidebarLayoutSaving}` | +| New project | `PlusIcon`, `id="add-project-button"` | `aria-label={newProject}` → **"New Project"** | +| Body | `ProjectGroupSection` or `ProjectMenuItem` per `topEntries` | — | + +**Dialog launched:** `CreateProjectDialog` via plus button. + +### 4.7 Reorder mode (`isReorderMode === true`) + +Replaces normal pinned/projects rendering with drag-and-drop UI. + +#### 4.7.1 Pinned group (reorder) + +- Label: **"Pinned"** +- `DndContext` + `SortableContext` + `SortableProjectRow` per pinned project +- Trailing `SidebarDropZone` id=`sidebar-drop:pinned`, label `dropHereToPin`, `compact` when pinned list non-empty + +#### 4.7.2 Projects group (reorder) + +- Label row: **"Projects"** + **CheckIcon** action (`doneEditingProjectOrder`, `className="right-9 bg-sidebar-accent text-sidebar-accent-foreground"`) + **PlusIcon** (new project) +- Top-level sortable entries: + - **Group:** `SortableGroupRow` with nested `SidebarMenu className="pl-4"` containing nested `SortableProjectRow` + group drop zone + - **Project:** `SortableProjectRow` +- Bottom `SidebarDropZone` id=`sidebar-drop:top-level`, label `dropHereToUnpinOrMoveOut` + +#### 4.7.3 `SortableProjectRow` + +``` +SidebarMenuItem [opacity 0.45 when dragging] +├── SidebarMenuButton [data-sidebar-item, bg-sidebar-accent when dragging] +│ ├── span [cursor-grab] → DotsSixVerticalIcon (muted) +│ ├── ProjectAvatar +│ └── span → project.name +└── SidebarMenuAction + ├── aria-label: pinProject | unpinProject + ├── aria-pressed={isPinned} + └── StarIcon +``` + +- Drag activation: `PointerSensor` distance **5** px +- Pin toggle calls `handleTogglePinned` (disabled while save in flight) + +#### 4.7.4 `SortableGroupRow` + +Same drag handle + `FolderIcon` + group name; `SidebarMenuBadge` shows `{projects.length}`; children = nested project list + drop zone. + +#### 4.7.5 `SidebarDropZone` + +- Container: `mx-3 rounded-md border border-dashed px-3 text-center text-xs text-muted-foreground` +- Spacing: `compact ? "my-1 py-1" : "my-2 py-2"` +- **isOver:** `border-foreground/30 bg-muted`; else `border-border` + +### 4.8 Footer — Settings + +| Element | Icon | Label | Action | +|---------|------|-------|--------| +| `SidebarMenuButton` | `GearSixIcon` | `m.settings()` → **"Settings"** | `openSettingsWindow()` (separate Tauri window) | +| Attribute | `data-sidebar-item` | — | Included in arrow-key nav | + +### 4.9 Width resize separator + +``` +div[role=separator] + aria-label="Resize sidebar" ← hardcoded English, not i18n + aria-orientation="vertical" + aria-valuemin=220 aria-valuemax=420 aria-valuenow={sidebarWidth} + tabIndex=0 + className: + absolute top-0 right-[-4px] bottom-0 w-2 cursor-col-resize + before: absolute vertical 1px line at center + hover:before:bg-border + focus-visible:outline-2 outline-offset-[-2px] outline-(--app-focus-ring) + dragging: before:bg-foreground/30 +``` + +Keyboard on separator: ArrowLeft/Right ±16 px, Home=min, End=max. + +### 4.10 Keyboard navigation (`handleKeyDown` on `Sidebar`) + +- **ArrowDown / ArrowUp:** Cycle focus among `[data-sidebar-item]` elements inside sidebar ref. +- Does not wrap parent containers; flat query of all items in DOM order (includes nested profile rows when expanded). + +### 4.11 Global shortcuts affecting sidebar (from `App.tsx`) + +| Shortcut | Action | +|----------|--------| +| Cmd/Ctrl+, | `openSettingsWindow()` (same as footer) | +| Cmd/Ctrl+Shift+D | Debug panel (unrelated) | + +### 4.12 Error toast + +Failed layout persist: `toast.error(sidebarOrderUpdateFailed)` with error message description. + +--- + +## 5. `ProjectMenuItem.tsx` + +One row per project in the app sidebar (normal mode). + +### 5.1 Tree + +``` +SidebarMenuItem.group/project-item +├── ContextMenu +│ ├── ContextMenuTrigger → SidebarMenuButton → NavLink +│ │ ├── ProjectAvatar +│ │ └── span.min-w-0.flex-1.truncate.font-medium → project.name +│ └── ContextMenuContent +│ ├── ProjectGroupMenu (submenu) +│ ├── projectSettings +│ ├── renameProject +│ ├── [separator] +│ └── deleteProject (destructive) +├── [branch A] hasOnlyDefaultProfile +│ └── Tooltip → SidebarMenuAction (create profile) +│ ├── PlusIcon (hidden until group-hover/group-focus-within) +│ └── AgentStatusDot (hidden on hover/focus-within when indicator present) +├── [branch B] multiple profiles +│ └── SidebarMenuAction (expand/collapse) +│ └── CaretDownIcon | CaretRightIcon +└── [if expanded && !hasOnlyDefaultProfile] + └── SidebarMenuSub + ├── SidebarMenuSubItem → default profile row + ├── ProfileList (non-default profiles) + └── (CreateProfileDialog only in branch A on action) +``` + +### 5.2 Project row button + +- **Link target:** `/projects/{id}/profiles/{defaultProfileId}` or `/projects/{id}` if no default profile +- **Attributes:** `data-project-id`, `data-testid="project-sidebar-item"`, `data-sidebar-item` +- **Active when:** `hasOnlyDefaultProfile && activeProfileId === defaultProfile.id` +- **Text:** `project.name`, truncated, `font-medium` + +### 5.3 Trailing action — single-profile projects + +When `nonDefaultProfiles.length === 0`: + +| State | Visible control | +|-------|-----------------| +| Default | `AgentStatusDot` if agent status/completion exists | +| `:hover` / `:focus-within` on `.group/project-item` | `PlusIcon` (`hidden group-hover/project-item:block group-focus-within/project-item:block`) | + +- **Tooltip:** side `right`, text `createProfile` → **"New Profile"** +- **aria-label:** `createProfile` +- Click/Enter/Space → `CreateProfileDialog` + +Agent indicator source: `useProfileAgentStatus(defaultProfileId) ?? useProfileAgentCompletion(defaultProfileId)`. + +### 5.4 Trailing action — multi-profile projects + +- **Icon:** `CaretDownIcon` (expanded) or `CaretRightIcon` (collapsed) +- **aria-label:** `toggleProjectGroup({ name: project.name })` → **"Toggle project group {name}"** +- **aria-expanded:** `expanded` (default expanded: `userExpanded ?? true`) +- Click toggles local `userExpanded` state + +### 5.5 Default profile sub-row (multi-profile only) + +``` +SidebarMenuSubButton → NavLink(defaultProfileUrl) +├── TerminalWindowIcon +├── OverflowTooltipText(displayValue=defaultProfile, tooltipValue=defaultProfile) +└── AgentStatusDot (if indicator) +``` + +- **Label i18n:** `defaultProfile` → **"Default"** +- **Active when:** `activeProfileId === defaultProfile.id` + +### 5.6 Context menu items + +| Item | i18n key | English | Opens | +|------|----------|---------|-------| +| Submenu | `addToProjectGroup` | Add to Project Group | Inline submenu | +| | `projectSettings` | Project Settings | `ProjectSettingsDialog` | +| | `renameProject` | Rename | `RenameProjectDialog` | +| Separator | — | — | — | +| Destructive | `deleteProject` | Delete Project | `DeleteProjectDialog` | + +--- + +## 6. `ProjectGroupSection.tsx` + +Folder row grouping projects under a `ProjectGroup`. + +### 6.1 Tree + +``` +SidebarMenuItem +├── SidebarMenuButton [type=button, data-sidebar-item] +│ ├── CaretRightIcon | CaretDownIcon +│ └── span → group.name +├── SidebarMenuBadge → projects.length +└── AnimatePresence + └── motion.div (height/opacity animation, overflow hidden) + └── SidebarMenuSub [mx-0 translate-x-0 gap-0 border-l-0 px-0 py-0] + └── ProjectMenuItem × projects +``` + +### 6.2 Collapse state + +- Stored in `useAppSidebarStore.collapsedProjectGroupIds` (persisted) +- `collapsed === true` → caret right, children not rendered +- **aria-expanded:** `!collapsed` +- **aria-label:** `toggleProjectGroup({ name: group.name })` +- Enter/Space on button toggles + +### 6.3 Animation + +- Duration **0.18** s, ease `[0.22, 1, 0.36, 1]` +- `prefers-reduced-motion`: no height/opacity animation (exit keeps opacity 1; enter skips initial) + +--- + +## 7. `ProjectGroupMenu.tsx` (context submenu) + +Rendered inside project context menu. + +### 7.1 Structure + +``` +ContextMenuSub +├── ContextMenuSubTrigger → addToProjectGroup (truncated) +└── ContextMenuSubContent.min-w-56 + ├── [if no groups] div.px-3.py-2.text-sm.text-muted-foreground → noProjectGroups + ├── [else] ContextMenuItem per group + │ ├── CheckIcon (opacity-0 if not current) + │ └── span.truncate → group.name + ├── [if project in group] separator + removeFromProjectGroup (XIcon) + ├── separator + └── create flow OR createProjectGroup item +``` + +### 7.2 Create group inline input + +When `isCreating || projectGroups.length === 0`: +- Wrapper: `px-2 py-1.5` +- `Input` placeholder `projectGroupNamePlaceholder` → **"e.g. Work"** +- **Enter:** submit create + assign +- **Escape:** cancel, clear name + +### 7.3 Pending/disabled + +- `disabled={isPending || isCurrent}` on group items +- Errors: toast `somethingWentWrong` + description + +--- + +## 8. `ProjectAvatar.tsx` + +| Setting | Behavior | +|---------|----------| +| `useSidebarSettingsStore.showProjectAvatars` | default **true**, persisted `"sidebar-settings"` | +| When false | Renders **null** (no placeholder gap in button — icon slot omitted) | +| When true | `span.grid.size-4.shrink-0.place-items-center.overflow-hidden.rounded-md.bg-sidebar-accent.text-sidebar-accent-foreground` | + +**Image path:** `useProjectAvatar(projectId)` when enabled. + +**Fallback:** first grapheme of trimmed `projectName` uppercased, or `"?"`; text `text-[0.625rem] leading-none font-medium` (**10 px**). + +**Image:** `img.size-full.object-cover`, `alt={projectName}`, onError → fallback letter. + +--- + +## 9. `ProfileList.tsx` + `ProfileItem.tsx` + +### 9.1 ProfileList + +Maps `profiles` (non-default only) to `ProfileItem`, then append: + +``` +SidebarMenuSubItem +└── SidebarMenuSubButton [button, data-sidebar-item] + ├── PlusIcon weight=regular + └── span → createProfile ("New Profile") +``` + +Opens `CreateProfileDialog` for `projectId`. + +### 9.2 ProfileItem + +``` +SidebarMenuSubItem +├── ContextMenu +│ ├── ContextMenuTrigger → SidebarMenuSubButton → NavLink +│ │ ├── GitBranchIcon +│ │ ├── OverflowTooltipText(branch_name) +│ │ └── AgentStatusDot? +│ └── ContextMenuContent +│ └── deleteProfile (destructive) +└── DeleteProfileDialog +``` + +- **Route:** `/projects/{projectId}/profiles/{profile.id}` +- **Active:** `isActive` prop +- **Label:** `profile.branch_name` (not i18n — raw git branch name) + +--- + +## 10. `AgentStatusDot.tsx` + +| Property | Value | +|----------|-------| +| Size | `size-2` → **8×8 px** circle | +| `aria-hidden` | `true` (decorative) | +| `data-agent-status` | `waiting` \| `running` \| `completed` | + +| Status | Visual | +|--------|--------| +| `waiting` | `bg-yellow-400` | +| `completed` | `bg-green-500` | +| `running` | `bg-emerald-400` + pulsing shadow; class `agent-status-dot--running` | + +**Running animation** (`app.css`): `agent-status-pulse` 1.4s ease-in-out infinite; disabled under `prefers-reduced-motion`. + +--- + +## 11. `SidebarLink.tsx` + +Used only for Home link when no projects. + +``` +SidebarMenuItem +└── SidebarMenuButton → NavLink + ├── {icon} + └── span → {children} +``` + +- **Active:** `useMatch(pattern ?? to)` +- **`data-sidebar-item`:** yes + +--- + +## 12. `OverflowTooltipText.tsx` + +Used on profile branch names and default profile label. + +- Display: `span.min-w-0.truncate` (+ passed `className`) +- Tooltip: only when measured text overflows (>0.5 px); side **top**, align **start** +- Tooltip content: `max-w-[min(480px,calc(100vw-32px))] break-all whitespace-normal` +- Prop `tooltipDisabled` suppresses tooltip (not used in sidebar profile rows) + +--- + +## 13. Loading and error fallbacks — `Fallbacks.tsx` + +### 13.1 `SidebarSkeleton` + +``` +aside.w-[250px].shrink-0.border-r.bg-muted/40.p-4 +└── div.flex.flex-col.gap-3 + ├── Skeleton h-6 w-full + ├── Skeleton mt-2 h-3 w-1/2 + └── Skeleton ml-5 h-5 w-3/4 × 3 +``` + +Fixed width **250 px** (matches default sidebar width, not dynamic store width). + +### 13.2 `SidebarError` + +Same shell as skeleton (`w-[250px] … p-4`) with centered `ErrorStack`: +- Title: `somethingWentWrong` → **"Something went wrong"** +- Message: `error.message` +- Button: `tryAgain` → **"Try again"** + +--- + +## 14. Profile sidebar — `ProfileSidebar.tsx` + +Secondary left panel inside project view; not the app sidebar, but part of left-nav UX. + +### 14.1 Placement + +``` +ProfileLayout +└── flex row + ├── ProfileSidebar (this) + └── main content +``` + +Controlled by `ProfileLayout` state: `fileTreeOpen` (default **true**), `sidebarMode` (`"files"` \| `"git"` \| `"notes"`). + +### 14.2 Outer wrapper + +``` +div.h-full.shrink-0 + pointerEvents: isOpen ? auto : none + aria-hidden: !isOpen +└── motion.div + animate width: isOpen ? panelWidth : 0 + spring transition (stiffness 320, damping 34, mass 0.9) unless reduced motion or dragging + className: relative flex h-full min-w-0 flex-col + overflow: visible +``` + +### 14.3 Mode panels + +| Mode | Visibility | Content wrapper | +|------|------------|-----------------| +| `files` | `display: block/none` on wrapper | `FileTreePanel` | +| `git` | conditional render | `SidebarPanelContent` → `SidebarGitPanel` | +| `notes` | conditional render | `SidebarPanelContent` → `ProfileNotesEditor` | + +`SidebarPanelContent`: `div.flex.min-h-0.min-w-0.flex-1.flex-col.overflow-hidden.border-r` + `AsyncBoundary` (spinner / error). + +**Files tree stays mounted** when switching modes (`display:none` when not files). + +### 14.4 Resize separator (profile panel) + +``` +div[role=separator] + aria-label=profileSidebarResizeSeparator → "Resize sidebar" + aria-valuemin=180 aria-valuemax=560 aria-valuenow={panelWidth} + className: absolute top-0 -right-1 bottom-0 z-[1] w-2 cursor-col-resize + focus-visible:outline-2 -outline-offset-2 outline-[var(--app-focus-ring)] +``` + +Same keyboard resize behavior as app sidebar (±16 px, Home/End). + +--- + +## 15. `SidebarModeSwitch.tsx` (top bar control for profile sidebar) + +Located in `ProjectTopBar` left cluster — controls profile sidebar mode, not app sidebar. + +### 15.1 Structure + +``` +Tabs value={isOpen ? mode : null} +└── TabsList.h-7 + └── ×3 Tooltip + └── TabsTrigger.px-2 aria-label={label()} + ├── Icon className=size-3.5 (14px) + └── [git only] +{additions} text-green-500, -{deletions} text-red-500 +``` + +### 15.2 Tabs + +| value | Icon | i18n | English | +|-------|------|------|---------| +| `files` | `FolderSimpleIcon` | `sidebarFilesTab` | Files | +| `git` | `GitBranchIcon` | `sidebarGitTab` | Git | +| `notes` | `NoteIcon` | `notes` | Notes | + +**Git badge:** `useGitDiffStats(profileId, isActive)` when tab is git; shows `+N` / `-M` in `text-xs`. + +**Behavior (ProfileLayout):** Re-clicking active mode while open closes sidebar; switching mode opens sidebar. + +### 15.3 TabsList / TabsTrigger styling (from `tabs.tsx`) + +- List: `inline-flex … rounded-lg p-[3px] bg-muted`, height overridden to **28 px** (`h-7`) +- Trigger: `text-sm`, active gets `data-active:bg-background` + shadow + +--- + +## 16. Profile sidebar — Files mode (`FileTreePanel.tsx`) + +### 16.1 Container + +``` +div.flex.h-full.min-h-0.min-w-0.overflow-hidden +└── motion.div (opacity/x slide when isOpen) + └── div.relative.min-h-0.min-w-0.flex-1.border-r.px-1.5.py-1 + ├── FileTree (@pierre/trees) + context menus + ├── FileTreeRootContextMenu (positioned) + └── [error overlay] absolute inset-0 centered text-xs muted +``` + +### 16.2 File tree visual tokens (`FILE_TREE_HOST_STYLE`) + +| CSS variable override | Value | +|-----------------------|-------| +| `--trees-font-size-override` | **13px** | +| `--trees-border-radius-override` | **4px** | +| `--trees-level-gap-override` | **12px** | +| `--trees-item-margin-x-override` | **4px** | +| `--trees-item-padding-x-override` | **4px** | +| `--trees-padding-inline-override` | **4px** | +| Colors | map to `--muted`, `--muted-foreground`, `--foreground` | + +Density: `"compact"`, icons: `"complete"`, initial expansion: `"closed"`. + +### 16.3 Animation + +- Opacity 0→1, x -12→0 when opening; duration **0.18** s (same easing as project group) + +### 16.4 Context menus (sidebar-relevant) + +**Root menu items (i18n keys):** `fileTreeContextMenuNewFile`, `NewFolder`, `Refresh`, `RevealInFileManager`, `CopyRelativePath`, `CopyAbsolutePath` + +**Item menu adds:** Open, OpenInDefaultApp, Rename, Delete, etc. + +Popup: `min-w-40 rounded-lg bg-popover p-1 shadow-md ring-1 ring-foreground/10` + +### 16.5 Error state + +Absolute overlay, `pointer-events-none`, shows `getErrorMessage(treePathsError)` in `text-xs text-muted-foreground`. + +--- + +## 17. Profile sidebar — Git mode (`SidebarGitPanel.tsx`) + +Vertical stack: + +``` +div.flex.min-h-0.min-w-0.flex-1.flex-col.overflow-hidden +├── ChangesFileList (tooltipsDisabled, emptyMessage=noChangesDetected, onMaximize) +└── CommitComposer +``` + +Plus modal `GitDiffDialog` (not inline in panel). + +### 17.1 ChangesFileList header (sticky) + +``` +div.sticky.top-0.z-[1].flex.items-center.gap-2.border-b.bg-background.px-3.py-2.5 +├── Checkbox (include all/none) aria-label=gitCommitIncludeAll → "All" +├── p.text-xs.text-muted-foreground → changedFiles({ count }) +└── [optional] Button size=xs ghost ml-auto size-6 → ArrowsOutSimpleIcon (gitOpenDiffView) +``` + +**Empty state:** centered `p-6`, `text-xs text-muted-foreground` → **"No changes detected"** + +### 17.2 File row (`FileListItem`) + +``` +div.flex.w-full.items-start.gap-2.px-3.py-2 +├── Checkbox.mt-0.5 +├── OverflowTooltipText basename (text-sm, font-medium if active) +├── optional parent path (text-xs muted) +└── Badge size-4 (change type letter) +``` + +- **Active row:** `bg-muted` +- **Inactive hover:** `hover:bg-muted/70` +- **Excluded:** `opacity-70` +- **Double-click:** opens `GitDiffDialog` focused on file +- **Context menu:** discard action (`gitDiscardFileAction`) + +Context menu width constant: **200** px. + +### 17.3 CommitComposer footer + +``` +div.shrink-0.border-t.px-2.5.py-2 +├── p.text-xs.font-medium.uppercase.text-muted-foreground → gitCommitSectionTitle ("Commit") +├── Field: gitCommitSummary + Input h-7 text-xs +├── Field: gitCommitBody + Textarea min-h-[4.5rem] text-xs +└── flex justify-end gap-2 + ├── [if no files] Push button (UploadSimpleIcon, optional ahead count) + └── [else] Commit button (Spinner when pending) +``` + +**Commit shortcut:** Cmd/Ctrl+Enter when `canSubmit`. + +--- + +## 18. Profile sidebar — Notes mode + +`ProfileNotesEditor` → `MarkdownEditor` full flex area inside `SidebarPanelContent`. + +| UI element | i18n | +|------------|------| +| Placeholder | `notesPlaceholder` | +| Save failed toast | `notesSaveFailedTitle` | +| Editor save status labels | `notesSaved`, `notesSaveFailedShort` (in MarkdownEditor) | + +--- + +## 19. `WindowControls.tsx` (Windows platform) + +Not part of sidebar column but affects top-bar padding when app sidebar collapsed. + +``` +div.fixed.top-0.right-0.flex.h-7 [data-window-controls] +├── Minimize (MinusIcon 12px) aria-label="Minimize" +├── Maximize/Restore (SquareIcon | CopySimpleIcon 12px) +└── Close (XIcon 12px) aria-label="Close" +``` + +| Button | Size | Hover | +|--------|------|-------| +| Minimize / Maximize | `h-7 w-9` (28×36 px) | `hover:bg-muted` | +| Close | same | `hover:bg-[#c42b1c] hover:text-white` | + +`[-webkit-app-region:no-drag]` on buttons. + +`ProjectTopBar` adds `pr-[118px]` on Windows to clear controls. + +--- + +## 20. Platform differences + +### 20.1 macOS + +| Location | Adjustment | +|----------|------------| +| `AppSidebar` `SidebarHeader` | `pt-8` (**32 px**) — comment: clear traffic lights at y:26+12px | +| `ProjectTopBar` | When app sidebar collapsed: `pl-[84px]` to clear traffic lights | +| Window controls | Native overlay (not `WindowControls` component) | +| Header drag | `data-tauri-drag-region` on sidebar header + top bar | + +### 20.2 Windows + +| Location | Adjustment | +|----------|------------| +| `AppSidebar` header | `pt-2` (**8 px**) | +| `WindowControls` | Rendered fixed top-right | +| `ProjectTopBar` | `pr-[118px]` for custom window buttons | + +### 20.3 Linux / other + +Same as Windows for header padding (`pt-2`); no `WindowControls` unless Windows platform check passes. + +--- + +## 21. Keyboard shortcuts summary (sidebar-related) + +| Context | Shortcut | Action | +|---------|----------|--------| +| App | Cmd/Ctrl+, | Open settings window | +| App sidebar | ArrowUp/Down | Focus prev/next `[data-sidebar-item]` | +| App sidebar resize | ArrowLeft/Right | ±16 px width | +| App sidebar resize | Home / End | Min / max width | +| ProjectTopBar (active profile) | Cmd/Ctrl+E | Toggle profile sidebar open/closed | +| ProjectTopBar (active profile) | Cmd/Ctrl+G | Open git diff dialog (not sidebar panel) | +| Profile panel resize | ArrowLeft/Right, Home, End | Same as app sidebar | +| Commit composer | Cmd/Ctrl+Enter | Commit when valid | +| Project group menu input | Enter / Escape | Create / cancel | +| shadcn SidebarProvider | Cmd/Ctrl+B | Toggle shadcn sidebar — **not wired** for AppSidebar collapse | + +--- + +## 22. Persisted client state + +| Store | Key | Fields | +|-------|-----|--------| +| `useAppSidebarStore` | `app-sidebar-width` | `width`, `isCollapsed`, `collapsedProjectGroupIds` | +| `useSidebarSettingsStore` | `sidebar-settings` | `showProjectAvatars` (default true) | +| Profile panel width | `file-tree-panel` | `{ state: { panelWidth }, version: 2 }` | + +--- + +## 23. Complete i18n key → English map (sidebar scope) + +| Key | English text | +|-----|--------------| +| `home` | Home | +| `sideNavLabel` | Side navigation | +| `collapseSidebar` | Collapse sidebar | +| `expandSidebar` | Expand sidebar | +| `settings` | Settings | +| `pinnedProjects` | Pinned | +| `sidebarProjectsSection` | Projects | +| `editProjectOrder` | Edit project order | +| `doneEditingProjectOrder` | Done editing project order | +| `newProject` | New Project | +| `pinProject` | Pin project | +| `unpinProject` | Unpin project | +| `dropHereToPin` | Drop here to pin | +| `dropProjectIntoFolder` | Drop project into folder | +| `dropHereToUnpinOrMoveOut` | Drop here to unpin or move out | +| `sidebarOrderUpdateFailed` | Failed to update sidebar order | +| `toggleProjectGroup` | Toggle project group {name} | +| `defaultProfile` | Default | +| `createProfile` | New Profile | +| `projectSettings` | Project Settings | +| `renameProject` | Rename | +| `deleteProject` | Delete Project | +| `deleteProfile` | Delete Profile | +| `addToProjectGroup` | Add to Project Group | +| `noProjectGroups` | No project groups yet. | +| `removeFromProjectGroup` | Remove from Project Group | +| `projectGroupNamePlaceholder` | e.g. Work | +| `createProjectGroup` | Create Project Group | +| `somethingWentWrong` | Something went wrong | +| `tryAgain` | Try again | +| `sidebarFilesTab` | Files | +| `sidebarGitTab` | Git | +| `notes` | Notes | +| `profileSidebarResizeSeparator` | Resize sidebar | +| `noChangesDetected` | No changes detected | +| `changedFiles` | {count} changed file(s) | +| `gitOpenDiffView` | Open diff view | +| `gitCommitIncludeAll` | All | +| `gitCommitSectionTitle` | Commit | +| `gitCommitSummary` | Summary | +| `gitCommitSummaryPlaceholder` | Describe the changes you're committing | +| `gitCommitBody` | Description | +| `gitCommitBodyPlaceholder` | Add an optional extended description | +| `gitCommitButton` | Commit | +| `gitPushButton` | Push | +| `gitDiscardFileAction` | Discard changes to this file | +| `notesPlaceholder` | (see messages/en.json) | +| `notesSaveFailedTitle` | Notes save failed | + +**Hardcoded non-i18n strings in sidebar UI:** `"2Code"` (brand), resize separator `aria-label="Resize sidebar"` on app sidebar, Windows control labels `"Minimize"`, `"Maximize"`, `"Restore"`, `"Close"`. + +--- + +## 24. Dialogs and menus launched from sidebar surfaces + +| Trigger location | UI | Component | +|------------------|-----|-----------| +| Projects `+` | New project | `CreateProjectDialog` | +| Project context menu | Settings / Rename / Delete | respective dialogs | +| Project context submenu | Group assign/create | inline + API | +| Single-profile `+` action | New profile | `CreateProfileDialog` | +| ProfileList footer | New profile | `CreateProfileDialog` | +| Profile context menu | Delete profile | `DeleteProfileDialog` | +| Settings footer | Settings | `openSettingsWindow()` | +| Git panel maximize | Full diff | `GitDiffDialog` | +| File tree context menus | File operations | `@pierre/trees` + dropdown items | + +--- + +## 25. Interactive state matrix (app sidebar) + +| State | Visual evidence in code | +|-------|-------------------------| +| Nav item hover | `SidebarMenuButton` / `SubButton` accent background | +| Nav item active/route | `data-active` / `isActive` → accent bg + medium font (project name) | +| Project row hover (single profile) | Swap agent dot ↔ plus icon | +| Reorder dragging | opacity **0.45**, optional `bg-sidebar-accent` on button | +| Drop zone hover | dashed border `border-foreground/30`, `bg-muted` | +| Reorder saving | actions `disabled={isSidebarLayoutSaving}` | +| Group collapsed | caret right, animated height 0 | +| Project expanded (multi) | caret down + `SidebarMenuSub` visible | +| Sidebar collapsed | entire `AppSidebar` unmounted | +| Loading projects | `SidebarSkeleton` 250 px | +| Load error | `SidebarError` with retry | + +--- + +## 26. Data model driving layout (`sidebarLayout.ts`) + +**Pinned:** projects with `pinned_order != null`, sorted by pinned_order. + +**Top entries:** merge of `ProjectGroup` entries (with nested grouped projects) and ungrouped top-level projects; sorted by `sort_order` / `created_at`. + +**Reorder persistence:** `createSidebarLayoutUpdates` emits sort orders in steps of **1000** (`SIDEBAR_ORDER_STEP`). + +Drop zone IDs: +- `sidebar-drop:pinned` +- `sidebar-drop:top-level` +- `sidebar-drop:group:{groupId}` + +Entry IDs: `project:{id}`, `group:{id}`. + +--- + +## 27. Parent → child index (quick reference) + +``` +AppSidebar +├── Header: brand + collapse +├── Content +│ ├── [empty] Home link +│ ├── [normal] Pinned → ProjectMenuItem* +│ ├── [normal] Projects → ProjectGroupSection* | ProjectMenuItem* +│ └── [reorder] Pinned DnD + Projects DnD +├── Footer: Settings +└── Resize handle + +ProjectMenuItem +├── Project row (avatar, name, context menu) +├── Action (plus+dot | caret) +└── Sub: Default row + ProfileList + dialogs + +ProjectGroupSection +├── Group row (caret, name, badge) +└── Sub: ProjectMenuItem* + +ProfileSidebar +├── FileTreePanel | SidebarGitPanel | ProfileNotesEditor +└── Resize handle + +ProjectTopBar (when app sidebar collapsed) +└── Expand sidebar button (SidebarSimpleIcon) +``` + +This document is intended as the single source of truth for reimplementing the sidebar UI in a non-React/non-web stack while preserving layout, spacing, states, and behavior. diff --git a/docs/ui-inventory-home-project.md b/docs/ui-inventory-home-project.md new file mode 100644 index 00000000..c9c01dd2 --- /dev/null +++ b/docs/ui-inventory-home-project.md @@ -0,0 +1,948 @@ +# 2code UI Inventory — Home, Project Detail, Profile Layout, File Tree/Viewer, Notes, Dialogs + +Framework-agnostic inventory derived from source files listed in the exploration task. Numeric sizes and class names are taken directly from code. English labels reference `messages/en.json` keys and resolved text. + +--- + +## 1. Global routing context + +| Route | Page component | Notes | +|-------|----------------|-------| +| `/` | `HomePage` | Auto-redirects to first project's default profile when projects exist | +| `/projects/:id/profiles/:profileId` | `ProjectDetailPage` + persistent `TerminalLayer` overlay | Invalid/missing profile redirects to default profile or `/` | + +**Project detail rendering split:** + +- **`TerminalLayer`** (absolute overlay, `inset: 0`, `display: flex/none` per active profile): renders `ProfileLayout` + `TerminalTabs` for every profile that has open terminal or file tabs. +- **`ProjectDetailPage`**: only mounts `ProfileLayout` + `TerminalTabs` when **both** terminal tabs and file tabs are empty (`shouldRenderEmptyState`), showing the empty-terminal CTA. Once any tab opens, `TerminalLayer` owns the view; `ProjectDetailPage` renders `null`. + +--- + +## 2. Home Page (`HomePage.tsx`) + +### 2.1 Component tree + +``` +HomePage (root: div.h-full) +├── header (data-tauri-drag-region) +│ ├── FolderIcon (Phosphor, className: size-4 text-muted-foreground) +│ └── h1 (select-none text-sm font-semibold) → i18n: home ("Home") +├── [conditional: hasNoProjects] +│ └── div (flex h-[calc(100%-52px)] items-center justify-center) +│ └── Empty +│ └── EmptyHeader +│ ├── EmptyMedia variant="icon" → FolderPlusIcon +│ ├── EmptyTitle → emptyProjectsTitle ("No projects yet") +│ └── EmptyDescription → emptyProjectsDesc +└── TourOnboarding (renders null; driver.js overlay when enabled) +``` + +### 2.2 Layout regions + +| Region | Dimensions / classes | Behavior | +|--------|---------------------|----------| +| Page root | `h-full` | Fills main content area beside app sidebar | +| Header bar | `h-[52px]`, `border-b`, `px-5`, `flex items-center gap-2` | macOS draggable title region | +| Empty state area | `h-[calc(100%-52px)]`, centered flex | Only when `projects.length === 0` | +| Content when projects exist | Header only visible briefly | `useEffect` navigates to `/projects/{id}/profiles/{defaultProfileId}` with `replace: true` | + +### 2.3 Interactive states + +| State | UI | +|-------|-----| +| No projects | Centered empty state with folder-plus icon | +| Has projects | Immediate redirect; user rarely sees Home content except header flash | +| Onboarding tour active | driver.js popover anchored to `#add-project-button` in app sidebar (not in HomePage DOM) | + +### 2.4 TourOnboarding (`TourOnboarding.tsx`) + +- **Trigger:** `isEnabled={hasNoProjects}` on HomePage. +- **Library:** driver.js (`driver-popover-theme` class). +- **Single step:** targets `#add-project-button` (in `AppSidebar.tsx`). +- **Popover:** side `right`, align `start`; title `onboardingTourTitle`, description `onboardingTourDesc`; buttons: close only. +- **Dismiss:** clicking add-project button destroys tour. +- **Delay:** 300ms before `drive()`. +- **Renders:** `null` (no visible React output). + +### 2.5 Keyboard shortcuts + +None defined on HomePage. + +--- + +## 3. Project Detail Page (`ProjectDetailPage.tsx`) + +### 3.1 Component tree (empty state path) + +``` +ProjectDetailPage +└── [when !hasTabs && !hasFileTabs] + └── ProfileLayout + └── TerminalTabs + ├── Tab bar (empty) + └── emptyFallback → emptyTerminalState + └── Empty (centered) + ├── EmptyHeader + │ ├── EmptyMedia icon → TerminalWindowIcon + │ ├── EmptyTitle → noTerminalsOpen + │ └── EmptyDescription → noTerminalsOpenDescription + └── EmptyContent + └── div.flex + ├── Button (New Terminal) [+ PlusIcon, newTerminal] + └── [optional] DropdownMenu (template picker) + ├── DropdownMenuTrigger → Button size="icon" [-ml-px rounded-l-none, aria-label="Choose template"] + │ └── CaretDownIcon + └── DropdownMenuContent (min-w-56 p-1) + └── TerminalTemplateDropdownContent +``` + +### 3.2 Redirect logic (no visible UI) + +- Missing project → `` +- Missing profile → redirect to default profile or first profile, else `/` + +### 3.3 Empty terminal CTA buttons + +| Control | Label (i18n) | Classes / props | Disabled when | +|---------|--------------|-----------------|---------------| +| Primary | `newTerminal` ("New Terminal") | `Button`; if templates exist: `rounded-r-none` | `createTab.isPending` | +| Template split | (icon only) | `Button` `size="icon"`, `-ml-px rounded-l-none`, `aria-label="Choose template"` | same | + +### 3.4 Keyboard shortcuts (via `TerminalLayer`, active profile) + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl+T` | Create new terminal tab | +| `Cmd/Ctrl+W` | Close active terminal tab | + +--- + +## 4. Profile Layout (`ProfileLayout.tsx`) + +### 4.1 Component tree + +``` +ProfileLayout (div.flex.h-full.flex-col) +├── CommandPalette (modal, always mounted) +├── div.border-b +│ └── ProjectTopBar +└── div.flex.min-h-0.min-w-0.flex-1 + ├── ProfileSidebar + └── div.min-h-0.min-w-0.flex-1 + └── {children} → TerminalTabs +``` + +### 4.2 Local state + +| State | Default | Purpose | +|-------|---------|---------| +| `fileTreeOpen` | `true` | Sidebar panel visible | +| `sidebarMode` | `"files"` | `"files" \| "git" \| "notes"` | + +### 4.3 Sidebar toggle behavior + +- **`onToggleFileTree`:** flips `fileTreeOpen`. +- **`onSidebarModeChange(mode)`:** + - If sidebar open AND same mode clicked → close sidebar. + - Else set mode and open sidebar. + +### 4.4 File open handler + +`openFileTab(profile.id, filePath)` → `useFileViewerTabsStore.openFile`. + +--- + +## 5. Profile Sidebar (`ProfileSidebar.tsx`) + +### 5.1 Component tree + +``` +ProfileSidebar (div.h-full.shrink-0) +└── motion.div (animated width) + ├── [files mode, display block/none] FileTreePanel + ├── [mode === git] SidebarPanelContent → SidebarGitPanel + ├── [mode === notes] SidebarPanelContent + │ └── div → ProfileNotesEditor (lazy) + └── [isOpen] resize separator (role="separator") +``` + +### 5.2 Width & animation constants + +| Constant | Value | +|----------|-------| +| `SIDEBAR_PANEL_MIN_WIDTH` | 180px | +| `SIDEBAR_PANEL_MAX_WIDTH` | 560px | +| `DEFAULT_SIDEBAR_PANEL_WIDTH` | 208px | +| `SIDEBAR_PANEL_STORAGE_KEY` | `"file-tree-panel"` (localStorage JSON `{ state: { panelWidth }, version: 2 }`) | +| Spring transition | stiffness 320, damping 34, mass 0.9 | +| Closed width | 0 (animate) | +| Reduced motion | `{ duration: 0 }` | + +### 5.3 Resize handle + +| Property | Value | +|----------|-------| +| `aria-label` | `profileSidebarResizeSeparator` ("Resize sidebar") | +| `aria-orientation` | `vertical` | +| `aria-valuemin/max/now` | 180 / 560 / current width | +| `tabIndex` | 0 | +| Classes | `absolute top-0 -right-1 bottom-0 z-[1] w-2 cursor-col-resize focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--app-focus-ring)]` | +| Pointer | `useHorizontalResize` drag | +| Keyboard | ArrowLeft/Right ±16px; Home=min; End=max | + +### 5.4 Pointer events when closed + +- Container: `pointerEvents: none`, `aria-hidden={true}` when closed. + +### 5.5 SidebarPanelContent wrapper + +``` +div.flex.min-h-0.min-w-0.flex-1.flex-col.overflow-hidden.border-r +└── AsyncBoundary + ├── fallback: LoadingSpinner + ├── error: LoadingError + └── children +``` + +### 5.6 Git mode panel (`SidebarGitPanel` — child, not in read list but mounted here) + +``` +div.flex.min-h-0.min-w-0.flex-1.flex-col.overflow-hidden +├── ChangesFileList (scrollable file list + commit inclusion) +└── CommitComposer (summary, body, commit/push) +``` + +**ChangesFileList header (sticky):** + +- Master checkbox (include all/none) → `gitCommitIncludeAll` aria-label +- Text: `changedFiles({ count })` +- Optional maximize button → `gitOpenDiffView`, `ArrowsOutSimpleIcon`, `size-6` +- Empty: `noChangesDetected` + +**Keyboard:** none specific to sidebar git panel. + +--- + +## 6. Sidebar Mode Switch (`SidebarModeSwitch.tsx`) + +### 6.1 Component tree + +``` +Tabs (value = isOpen ? mode : null) +└── TabsList (h-7) + └── [for each mode: files, git, notes] + └── Tooltip + └── TooltipTrigger → TabsTrigger (px-2, aria-label=label) + ├── Icon (size-3.5): FolderSimpleIcon | GitBranchIcon | NoteIcon + └── [git only, if diffStats] +{additions} (text-green-500) -{deletions} (text-red-500) +``` + +### 6.2 Mode labels (i18n) + +| Mode | Key | English | +|------|-----|---------| +| files | `sidebarFilesTab` | Files | +| git | `sidebarGitTab` | Git | +| notes | `notes` | Notes | + +### 6.3 Interaction + +- Click tab → `onModeChange(value)` (also wired to Tabs `onValueChange`). +- Git tab shows live diff stat badges when `useGitDiffStats(profileId, isActive)` returns data. +- When sidebar closed, `Tabs` value is `null` (no tab appears selected). + +--- + +## 7. File Tree Panel (`FileTreePanel.tsx`) + +### 7.1 Component tree + +``` +FileTreePanel (div.flex.h-full.min-h-0.min-w-0.overflow-hidden) +└── motion.div (opacity/x slide when isOpen) + └── div.relative.min-h-0.min-w-0.flex-1.border-r.px-1.5.py-1 + ├── FileTree (@pierre/trees) + │ └── renderContextMenu → FileTreeContextMenu + ├── [rootContextMenu] FileTreeRootContextMenu + └── [isTreePathsError] error overlay (absolute inset-0, pointer-events-none) + └── p.text-xs.text-muted-foreground (error message) +``` + +### 7.2 Pierre FileTree configuration + +| Option | Value | +|--------|-------| +| `density` | `"compact"` | +| `flattenEmptyDirectories` | `false` | +| `icons` | `"complete"` | +| `initialExpansion` | `"closed"` | +| `stickyFolders` | `true` | +| Drag-and-drop | move within tree; also terminal drop payload | +| Renaming | inline; draft create uses `startRenaming(..., { removeIfCanceled: true })` | + +### 7.3 CSS variables (`FILE_TREE_HOST_STYLE`) + +| Variable | Value | +|----------|-------| +| `--trees-font-size-override` | `13px` | +| `--trees-level-gap-override` | `12px` | +| `--trees-border-radius-override` | `4px` | +| `--trees-item-margin-x-override` | `4px` | +| `--trees-item-padding-x-override` | `4px` | +| `--trees-padding-inline-override` | `4px` | +| `--trees-bg-override` | `transparent` | +| `--trees-selected-bg-override` | `var(--muted)` | +| Host padding | `px-1.5 py-1` on wrapper | + +### 7.4 Animation + +| Property | Open | Closed | +|----------|------|--------| +| opacity | 1 | 0 | +| x | 0 | -12 | +| duration | 0.18s ease `[0.22, 1, 0.36, 1]` | same / 0 if reduced motion | + +### 7.5 Tree item interaction + +| Action | Behavior | +|--------|----------| +| Click file | Opens in file viewer tab (`onOpenFile`) | +| Click directory | Expand/collapse | +| Selection change (keyboard) | Opens single selected file unless suppressed | +| MouseDown | Suppresses auto-open on selection (multi-select) | +| Meta/Ctrl/Shift click | Does not auto-open on selection | +| Drag start | Writes terminal drop payload; suppresses click-open 500ms | +| KeyUp on focused dir | Sync expand/collapse state | + +**Draft create names:** `"New File"`, `"New Folder"` (English hardcoded in `FILE_TREE_CREATE_NAMES`). + +### 7.6 Context menu — item (`FileTreeContextMenu`) + +Rendered via `FileTreeMenu` → Base UI Menu portal. + +| Item | i18n key | Enabled when | +|------|----------|--------------| +| Open | `fileTreeContextMenuOpen` | file kind + in filePathSet | +| Open in Default App | `fileTreeContextMenuOpenInDefaultApp` | path deletable/exists | +| Reveal in Finder | `fileTreeContextMenuRevealInFileManager` | same | +| — separator — | | | +| Refresh | `fileTreeContextMenuRefresh` | !isRefreshing | +| — separator — | | | +| New File | `fileTreeContextMenuNewFile` | always | +| New Folder | `fileTreeContextMenuNewFolder` | always | +| Rename | `rename` | single path in tree | +| — separator — | | | +| Copy Relative Path | `fileTreeContextMenuCopyRelativePath` | always | +| Copy Absolute Path | `fileTreeContextMenuCopyAbsolutePath` | always | +| — separator — | | | +| Delete | `delete` | all selected deletable; variant destructive | + +**Popup classes:** `z-50 max-h-[var(--available-height)] min-w-40 overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10`. + +### 7.7 Context menu — empty/root area (`FileTreeRootContextMenu`) + +| Item | i18n | +|------|------| +| New File | `fileTreeContextMenuNewFile` | +| New Folder | `fileTreeContextMenuNewFolder` | +| Refresh | `fileTreeContextMenuRefresh` | +| Reveal in Finder | `fileTreeContextMenuRevealInFileManager` | +| Copy Relative Path (`.`) | `fileTreeContextMenuCopyRelativePath` | +| Copy Absolute Path (root) | `fileTreeContextMenuCopyAbsolutePath` | + +### 7.8 Loading / error states + +| State | UI | +|-------|-----| +| Tree paths loading | Pierre tree empty until data (no explicit spinner in panel) | +| Tree paths error | Centered overlay text: error message, `text-xs text-muted-foreground` | +| Create failure toast | `fileTreeCreateErrorTitle` + description | +| Delete failure toast | `fileTreeDeleteErrorTitle` + description | +| Refresh failure toast | `somethingWentWrong` | + +### 7.9 Git status integration + +- Status-only paths appear in tree (including deleted, ignored). +- Deleted files: visible but not openable on click. +- Status badges rendered by Pierre model via `setGitStatus`. + +### 7.10 Query gating + +When `!isOpen || !isActive`: file tree and git status queries disabled. + +--- + +## 8. File Viewer (`FileViewerPane.tsx` + tab integration in `TerminalTabs.tsx`) + +### 8.1 Tab bar integration + +File tabs share the horizontal tab strip with terminal tabs. + +**File tab trigger (`TabsTrigger`):** + +``` +TabsTrigger (max-w-56 flex-none justify-start, nativeButton=false, render=
) +├── FileTreeFileIcon (size=14) +├── span.truncate (filename title) +├── [if dirty] span.size-2.rounded-full.bg-muted-foreground (unsaved dot) +└── TabCloseButton (XIcon size-3, aria-label="Close {title}") +``` + +**Terminal tab trigger:** same layout class; terminal/agent icon; optional `AgentStatusDot`; close button. + +**Trailing control:** `TerminalTemplateMenu` (+ new terminal / templates dropdown). + +**Tab strip container:** `flex w-full min-w-0 items-center overflow-x-auto overflow-y-hidden border-b p-0`. + +### 8.2 FileViewerPane decision tree + +``` +FileViewerPane +├── [previewableBinaryFile] → FilePreviewPane +├── [loading && !hasLoadedFile] → Spinner in h-32 center +├── [error && !hasLoadedFile] → error text h-32 center +├── [!hasLoadedFile] → null +├── [.md/.mdx] → MarkdownEditor +└── else → Monaco Editor +``` + +### 8.3 Monaco editor pane + +| Property | Value | +|----------|-------| +| Container | `div.h-full.min-h-0.overflow-hidden` ref for save focus | +| Height | `100%` | +| Theme | `light` or `vs-dark` from terminal theme id | +| Font | terminal settings `fontFamily`, `fontSize` | +| Options | minimap off; padding top/bottom 12; ligatures; wordWrap off; no TS/JS validation | +| Loading | centered Spinner | +| Unsaved | tracked in `fileViewerDirtyStore`; 400ms draft debounce (`DRAFT_SYNC_DELAY_MS`) | + +### 8.4 Markdown file pane + +Uses `MarkdownEditor` with `editorKey={filePath}`, `saveStatus` from save mutation (`saving` \| `idle`). + +### 8.5 FilePreviewPane (binary/archive/image/pdf) + +**Header:** `min-h-9`, `border-b`, `bg-muted`, `px-3` + +| Element | Content | +|---------|---------| +| Left | filename, `truncate text-sm font-medium` | +| Right | `"Office Preview"` if kind `office-pdf`, else hardcoded `"Preview"` (not i18n) | + +**Archive:** `ArchivePreviewTree` instead of generic preview. + +**Image:** checkerboard background grid, `object-fit: contain`, max dimensions 100%. + +**PDF:** full-size iframe, white background, border 0. + +**Loading:** Spinner centered. + +**Error:** `text-sm text-muted-foreground` centered, max-w-lg. + +**Unavailable:** hardcoded `"Preview unavailable"`. + +**Previewable extensions:** images (png, jpg, …), pdf, office docs, archives (.zip, .tar, .tar.gz, .tgz, .gz). + +### 8.6 ArchivePreviewTree + +``` +div.flex.h-full.min-h-0.flex-col.overflow-hidden +├── header (min-h-9, border-b, bg-muted, px-3) +│ ├── filename (truncate text-sm font-medium) +│ └── "{fileCount} files / {directoryCount} folders" (hardcoded English) +└── FileTree (read-only, initialExpansion open, same 13px tree styles) +``` + +### 8.7 Terminal vs file content visibility + +- File viewer: conditionally rendered when `fileTabActive && activeFilePath`. +- Terminals: **never unmounted**; parent `display: none` when file tab active; each terminal `visibility hidden` except active tab. + +### 8.8 Unsaved close dialog (`UnsavedFileCloseDialog`) + +**Trigger:** closing file tab while path in `dirtyFilePathSet`. + +| Element | i18n | +|---------|------| +| Title | `closeUnsavedFileTitle` + WarningCircleIcon | +| Body | `closeUnsavedFileDescription({ file })` | +| Cancel | `cancel` | +| Discard | `discardChanges` (destructive) | + +### 8.9 Keyboard shortcuts (file editor) + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl+S` | Save (window listener + Monaco command) | + +--- + +## 9. Markdown Editor (`MarkdownEditor.tsx`) + +Used by profile notes and `.md`/`.mdx` file tabs. + +### 9.1 Component tree + +``` +MarkdownEditor (div.milkdown-wrapper.h-full.overflow-hidden) +└── MilkdownProvider + └── MilkdownEditor (div.flex.h-full.min-h-0.flex-col) + ├── MarkdownToolbar + └── div.min-h-0.flex-1.overflow-y-auto.p-4 + └── Milkdown (ProseMirror) +``` + +### 9.2 Toolbar layout (`MarkdownToolbar`) + +Container: `flex shrink-0 items-center gap-1 overflow-x-auto border-b bg-background px-2 py-1`. + +**Sections left → right:** + +1. **Command menu dropdown** (`notesCommandMenu`, ListIcon) — block types, quote, code block, table, divider. +2. **Separator** vertical `h-5`. +3. **Inline formatting buttons** (ghost icon-xs, active: `bg-muted text-foreground`): + - Bold (`notesFormatBold` + hint `⌘B`) + - Italic (`notesFormatItalic` + `⌘I`) + - Code (`notesFormatCode` + `⌘E`) + - Strike (`notesFormatStrike`, renders "S" with line-through) + - Link (`notesFormatLink`) +4. **Separator** +5. **List/quote buttons:** bullet list, ordered list (shows "1."), quote. +6. **Separator** +7. **Table menu** (`notesTableMenu`, TableIcon): insert table, add row/col, delete cells. +8. **Link editor inline** (when open): Input placeholder `https://`, apply (CheckIcon), remove (XIcon). +9. **SaveStatusIndicator** (flex-1 justify-end): Badge with Saving/Saved/Failed. + +### 9.3 Save status badge + +| Status | Icon | Label | Badge variant | +|--------|------|-------|---------------| +| saving | FloppyDiskIcon | `notesSaving` | secondary | +| saved | CheckIcon | `notesSaved` | default | +| failed | WarningCircleIcon | `notesSaveFailedShort` | destructive | +| idle | (spacer flex-1) | — | — | + +### 9.4 Slash command menu (typing `/`) + +Floating menu class `markdown-editor-slash-menu` with items: Paragraph, H1–H3, bullet/ordered list, quote, code block, table (3×3), divider. + +### 9.5 Placeholder + +CSS `data-placeholder` on empty paragraph; default i18n `notesPlaceholder` ("Write notes for this profile…") or prop override. + +### 9.6 Auto-save timing + +- Markdown change debounce: **650ms** before `onMarkdownChange`. +- Profile notes: saves via API on change (see ProfileNotesEditor). + +### 9.7 Keyboard + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl+S` | `onRequestSave` when pane focused (file tabs); notes editor omits handler unless passed | + +--- + +## 10. Profile Notes (`ProfileNotesEditor.tsx`) + +### 10.1 Component tree + +``` +ProfileNotesEditor +└── MarkdownEditor + editorKey={profile.id} + initialMarkdown={profile.notes} + placeholder={notesPlaceholder} + saveStatus={saveStatus from local state} +``` + +### 10.2 Save behavior + +- On markdown change (after editor debounce): calls `useUpdateProfileNotes` mutation. +- Status flow: `idle` → `saving` → `saved` (1.6s) → `idle`, or `failed` on error toast `notesSaveFailedTitle`. +- Skips save if markdown equals last saved. + +### 10.3 Layout + +Fills sidebar panel: `min-h-0 flex-1 overflow-hidden` inside `SidebarPanelContent`. + +--- + +## 11. Command Palette (`CommandPalette.tsx`) + +### 11.1 Component tree + +``` +Command.Dialog (cmdk) +├── [header] div.border-b.px-4.py-3 +│ └── Command.Input (placeholder, aria-label) +├── Command.List (max-h-[60vh] overflow-y-auto p-1) +│ ├── [error] CommandPaletteStatusMessage +│ ├── [empty] CommandPaletteEmptyState +│ └── CommandPaletteResultList +│ └── CommandPaletteResultItem × N +│ ├── FileTreeFileIcon (16) +│ ├── name (truncate text-sm) +│ └── parent path (truncate text-xs muted) +``` + +### 11.2 CSS (`app.css`) + +| Class | Styles | +|-------|--------| +| `project-command-palette__overlay` | fixed inset-0, z-index 1400, rgba(0,0,0,0.4) | +| `project-command-palette__dialog` | fixed, top 72px, centered, padding-inline 12px, z-index 1401 | +| `project-command-palette__root` | width min(100%, 40rem), max-height calc(100vh - 96px), border-radius 8px, popover background | + +### 11.3 Result item + +Classes: `flex min-w-0 select-none items-center gap-2 rounded px-3 py-2 data-[selected=true]:bg-muted`. + +### 11.4 Empty / error messages + +| Condition | Message | +|-----------|---------| +| No search, no results | `commandPaletteEmpty` | +| Search, no results | `commandPaletteNoResults` | +| API error, no cached results | error text via `getErrorMessage` | + +### 11.5 Keyboard + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl+K` | Open palette (capture phase, active profile only) | +| Arrow keys | Navigate results (cmdk `loop`) | +| Enter | Open selected file, close palette | +| Escape | Close (via onOpenChange) | + +**Note:** i18n keys `commandPaletteHint`, `commandPaletteOpenHint`, `commandPaletteFooterHint`, `commandPaletteResultCount` exist in en.json but are **not rendered** in current CommandPalette UI. + +--- + +## 12. Project Top Bar (`ProjectTopBar.tsx`) + +### 12.1 Component tree + +``` +ProjectTopBar +├── div[data-tauri-drag-region] (relative flex min-h-[44px] items-end justify-between px-4 pt-1 pb-1.5) +│ ├── leftContent +│ ├── titleContent (absolute centered) +│ └── controlsContent +├── ProjectSettingsDialog +├── SwitchBranchDialog +└── GitDiffDialog (branch-aware for default profile) +``` + +### 12.2 Bar padding (platform) + +| Platform / state | Class | +|------------------|-------| +| Windows | `pr-[118px]` | +| Default | `pr-5` | +| macOS + collapsed app sidebar | `pl-[84px]` | + +### 12.3 Left region + +| Element | When | Details | +|---------|------|---------| +| Expand sidebar button | App sidebar collapsed | `SidebarSimpleIcon`, ghost icon button, `expandSidebar` | +| SidebarModeSwitch | Always when props provided | see §6 | + +### 12.4 Center title region + +Classes: `pointer-events-none absolute inset-x-0 bottom-1.5 flex min-w-0 items-center justify-center gap-2 px-32`. + +| Element | Content | +|---------|---------| +| Project name button | `truncate font-semibold`; click → reveal worktree in file manager; tooltip shows `profile.worktree_path` | +| Branch button | `switchBranchTitle` aria-label | + +**Branch display rules:** + +- **Default profile + active:** live `GitBranchLabel` (fetched branch name) with GitBranchIcon. +- **Default profile + inactive:** no branch label shown. +- **Non-default profile:** static `profile.branch_name` with GitBranchIcon. + +### 12.5 Right controls + +- Dynamic topbar controls from registry (`visibleActiveControls`). +- Settings gear: `projectSettings`, secondary icon button. + +### 12.6 Topbar control components (`controls.tsx`) + +| Control ID | Button | Label pattern | Action | +|------------|--------|---------------|--------| +| github-desktop | icon-sm secondary | `topbarGithubDesktop` | Open GitHub Desktop at worktree | +| editor | icon-sm secondary | `topbarEditor` · {app name} | Open configured editor app | +| terminal | icon-sm secondary | `topbarTerminal` · {app name} | Open configured terminal app | +| pr-status | xs secondary with text | `#N {state}` | Open PR URL; tooltip `topbarPrTooltip` | + +**PR states:** Draft, Open, Merged, Closed (`topbarPrDraft/Open/Merged/Closed`). + +### 12.7 Keyboard shortcuts (active profile) + +| Shortcut | Action | +|----------|--------| +| `Cmd/Ctrl+G` | Open Git diff dialog (changes tab) | +| `Cmd/Ctrl+E` | Toggle file tree sidebar (`onToggleFileTree`) | + +--- + +## 13. Top Bar Settings UI (`topbar/*.tsx`) + +Configured in app Settings page, not on project detail directly. + +### 13.1 TopBarSettings + +``` +div.flex.max-w-2xl.flex-col.gap-6 +├── hint text (topbarDragHint) +├── DndContext +│ ├── TopBarPreview +│ ├── AvailableControls +│ └── DragOverlay → DraggableControl +├── Editor app NativeSelect (if apps detected) +├── Terminal app NativeSelect +└── Reset button (topbarResetDefaults) +``` + +**Loading:** `topbarDetectingApps`. + +### 13.2 TopBarPreview + +Mock bar: "My Project" + branch "main" + draggable active controls or `topbarNoControls`. + +### 13.3 AvailableControls + +Dashed border drop area; inactive controls or `topbarAllControlsActive`. + +### 13.4 DraggableControl + +`rounded-md border bg-muted p-2`; grab cursor; DotsSixVerticalIcon + control icon (16px); dragging opacity 0.4. + +--- + +## 14. Dialogs — Projects + +### 14.1 CreateProjectDialog + +**Open triggers:** App sidebar "New Project" button (`#add-project-button`). + +| Element | Details | +|---------|---------| +| Title | FolderPlusIcon + `createProject` | +| Empty folder state | Dashed border button, FolderIcon size-6, `chooseFolder` | +| Selected folder | Label `folder`, outline xs button to re-pick, `code` block with path | +| Field | `projectName` input, placeholder `projectNamePlaceholderFolder` | +| Description | Dynamic hint from folder/name state | +| Footer | Cancel + Create (Spinner when pending) | + +**Validation:** Create disabled until folder selected. + +**Success:** navigates to new project's first profile. + +**Enter key:** submits from name field. + +### 14.2 DeleteProjectDialog + +**Open trigger:** Project context menu in sidebar (`ProjectMenuItem`). + +| Element | i18n | +|---------|------| +| Title | TrashIcon + `deleteProject` | +| Body | `confirmDeleteProject` | +| Cancel | `cancel` | +| Delete | `delete` (destructive, Spinner when pending) | + +### 14.3 RenameProjectDialog + +**Open trigger:** Project context menu → Rename. + +| Element | i18n | +|---------|------| +| Title | PencilSimpleIcon + `renameProject` | +| Field | `newName` | +| Submit disabled | empty or unchanged name | +| Buttons | `cancel`, `rename` | + +**Focus:** `[data-rename-input]` on open. + +### 14.4 ProjectSettingsDialog + +**Open trigger:** Project context menu → Settings; also gear in ProjectTopBar. + +| Element | Details | +|---------|---------| +| Content width | `sm:max-w-lg` | +| Title | GearSixIcon + `projectSettings` | +| Loading | Spinner min-h-[200px] | +| Error | DialogBodyError | + +**Tabs:** + +| Tab | Icon | Content | +|-----|------|---------| +| scripts (default) | CodeIcon | Worktree dir + init/setup/teardown script textareas | +| templates | TerminalWindowIcon | ProjectTemplatesEditor | + +**Scripts tab fields:** + +| Field | Label | Description key | +|-------|-------|-----------------| +| worktreeDir | `projectWorktreeDir` | `projectWorktreeDirDesc`, placeholder `projectWorktreeDirPlaceholder` | +| initScript | `initScript` | `initScriptDesc`, 4-row mono textarea | +| setupScript | `setupScript` | `setupScriptDesc` | +| teardownScript | `teardownScript` | `teardownScriptDesc` | + +**Footer:** Cancel + Save (Spinner when pending). + +### 14.5 ProjectTemplatesEditor (`components/ProjectTemplatesEditor.tsx`) + +| Element | i18n | +|---------|------| +| Section title | `projectTerminalTemplates` | +| Description | `projectTerminalTemplatesDescription` | +| Add button | `addTerminalTemplate` | +| Empty | `noTerminalTemplates` | +| Row actions | edit (`editTerminalTemplate`), delete (`deleteTerminalTemplate`) | +| Row shows | name, command preview (mono), optional cwd | + +**Sub-dialog:** `TerminalTemplateDraftDialog` (from terminal feature) for create/edit. + +### 14.6 UnsavedFileCloseDialog + +See §8.8. + +--- + +## 15. Dialogs — Profiles + +### 15.1 CreateProfileDialog + +**Open triggers:** + +- `ProfileList` "New Profile" control. +- `ProjectMenuItem` when project has only default profile. + +| Element | i18n | +|---------|------| +| Title | GitBranchIcon + `createProfile` ("New Profile") | +| Field | `branchName`, placeholder `branchNamePlaceholder` | +| Footer | `cancel`, `create` | + +**Enter:** submits if not pending. + +**Success:** navigate to new profile route. + +### 15.2 DeleteProfileDialog + +**Open trigger:** Profile item context menu (`ProfileItem`). + +| Element | i18n | +|---------|------| +| Title | TrashIcon + `deleteProfile` | +| Body | `confirmDeleteProfile` | +| Checking state | Spinner + `deleteProfileCheckingGitStatus` | +| Risk alert | `deleteProfileGitWarningTitle` + combined warnings | +| Git check failed alert | `deleteProfileGitCheckFailedTitle/Description` | +| Delete button | `deleteProfileAnyway` if risk, else `delete` | + +**Delete disabled while:** git check fetching or delete pending. + +--- + +## 16. Shared components + +### 16.1 FileTreeFileIcon + +- SVG icon from `@pierre/trees` symbol set based on filename extension. +- Default size **14px** (command palette uses **16**). +- Props: `fileName`, optional `size`. +- `aria-hidden="true"`, `data-icon-name`, `data-icon-token`. +- Color via CSS var `--trees-file-icon-color-{token}`. + +### 16.2 OverflowTooltipText + +- Truncating span (`min-w-0 truncate`). +- Tooltip only when text overflows (canvas measurement via `@chenglou/pretext`). +- Tooltip: side top, align start, max-width `min(480px, calc(100vw - 32px))`, `break-all whitespace-normal`. +- Prop `tooltipDisabled` suppresses tooltip. + +**Used in:** sidebar profile labels (not in scoped file list but referenced by layout). + +--- + +## 17. Terminal tab strip extras (`TerminalTemplateMenu`) + +**New terminal tab trigger:** TabsTrigger value `__new-terminal__`, PlusIcon. + +**Dropdown sections:** Project templates, global templates, default terminal; empty hint `noTemplatesDropdownHint`. + +--- + +## 18. Complete keyboard shortcut reference (scoped surfaces) + +| Shortcut | Surface | Action | +|----------|---------|--------| +| Cmd/Ctrl+K | Profile (active) | Open command palette | +| Cmd/Ctrl+S | File editor / markdown file | Save file | +| Cmd/Ctrl+G | Profile (active) | Open git diff dialog | +| Cmd/Ctrl+E | Profile (active) | Toggle sidebar open/close | +| Cmd/Ctrl+T | Profile (active) | New terminal | +| Cmd/Ctrl+W | Profile (active) | Close terminal tab | +| Arrow L/R, Home, End | Profile sidebar resize handle | Adjust width | +| Enter | Command palette | Open file | +| Escape | Command palette / dialogs | Close | +| Enter | Rename/create dialogs | Submit where wired | + +--- + +## 19. i18n key index (English) — scoped features + +Keys listed in `messages/en.json` directly referenced by inventoried components: + +**Home:** `home`, `emptyProjectsTitle`, `emptyProjectsDesc`, `onboardingTourTitle`, `onboardingTourDesc` + +**Project detail / terminal empty:** `noTerminalsOpen`, `noTerminalsOpenDescription`, `newTerminal` + +**Sidebar:** `sidebarFilesTab`, `sidebarGitTab`, `notes`, `profileSidebarResizeSeparator`, `expandSidebar` + +**File tree menus:** `fileTreeContextMenuOpen`, `fileTreeContextMenuOpenInDefaultApp`, `fileTreeContextMenuRevealInFileManager`, `fileTreeContextMenuRefresh`, `fileTreeContextMenuNewFile`, `fileTreeContextMenuNewFolder`, `fileTreeContextMenuCopyRelativePath`, `fileTreeContextMenuCopyAbsolutePath`, `rename`, `delete`, `fileTreeCreateErrorTitle`, `fileTreeDeleteErrorTitle`, `somethingWentWrong` + +**Command palette:** `commandPaletteTitle`, `commandPalettePlaceholder`, `commandPaletteEmpty`, `commandPaletteNoResults`, `commandPaletteRoot` + +**File close:** `closeUnsavedFileTitle`, `closeUnsavedFileDescription`, `cancel`, `discardChanges` + +**Notes / markdown:** `notes`, `notesPlaceholder`, `notesSaving`, `notesSaved`, `notesSaveFailedShort`, `notesSaveFailedTitle`, all `notesFormat*`, `notesInsert*`, `notesTable*`, `notesCodeBlock*`, `notesCommandMenu`, `notesApplyLink`, `notesRemoveLink`, `preview` + +**Project dialogs:** `createProject`, `chooseFolder`, `folder`, `projectName`, `projectNamePlaceholderFolder`, `createProjectChooseFolderHint`, `createProjectHintFolderEmpty`, `createProjectHintFolderNamed`, `create`, `cancel`, `deleteProject`, `confirmDeleteProject`, `renameProject`, `newName`, `rename`, `projectSettings`, `scripts`, `templates`, `projectWorktreeDir*`, `initScript*`, `setupScript*`, `teardownScript*`, `scriptPlaceholder`, `save`, `projectTerminalTemplates*`, `addTerminalTemplate`, `editTerminalTemplate`, `deleteTerminalTemplate`, `noTerminalTemplates`, `terminalTemplate` + +**Profile dialogs:** `createProfile`, `branchName`, `branchNamePlaceholder`, `deleteProfile`, `confirmDeleteProfile`, `deleteProfileCheckingGitStatus`, `deleteProfileGitWarningTitle`, `deleteProfileLocalChangesWarning`, `deleteProfileUnpushedCommitsWarning`, `deleteProfileTotalDiffWarning`, `deleteProfileGitCheckFailedTitle`, `deleteProfileGitCheckFailedDescription`, `deleteProfileAnyway` + +**Top bar:** `projectSettings`, `switchBranchTitle`, `topbarGithubDesktop`, `topbarEditor`, `topbarTerminal`, `topbarPrStatus`, `topbarPr*`, `topbarPreview`, `topbarAvailable`, `topbarDragHint`, `topbarResetDefaults`, `topbarDetectingApps`, `topbarNoControls`, `topbarAllControlsActive`, `topbarEditorApp`, `topbarTerminalApp` + +**Git sidebar:** `noChangesDetected`, `changedFiles`, `gitCommitIncludeAll`, `gitOpenDiffView`, (+ commit composer keys) + +--- + +## 20. Dialog open trigger summary + +| Dialog | Trigger location | +|--------|------------------| +| CreateProjectDialog | AppSidebar new project button | +| DeleteProjectDialog | ProjectMenuItem context menu | +| RenameProjectDialog | ProjectMenuItem context menu | +| ProjectSettingsDialog | ProjectMenuItem context menu; ProjectTopBar gear | +| CreateProfileDialog | ProfileList; ProjectMenuItem (single-profile projects) | +| DeleteProfileDialog | ProfileItem context menu | +| UnsavedFileCloseDialog | Close dirty file tab in TerminalTabs | +| CommandPalette | Cmd/Ctrl+K | +| GitDiffDialog | Cmd/Ctrl+G; git sidebar maximize; double-click changed file | +| SwitchBranchDialog | Click branch in ProjectTopBar | + +--- + +*Generated from codebase exploration. Hardcoded English strings not in en.json are called out explicitly (e.g. "Preview unavailable", archive file counts, "Choose template", draft "New File"/"New Folder").* diff --git a/docs/ui-inventory-settings-terminal-git-debug-updater.md b/docs/ui-inventory-settings-terminal-git-debug-updater.md new file mode 100644 index 00000000..23059977 --- /dev/null +++ b/docs/ui-inventory-settings-terminal-git-debug-updater.md @@ -0,0 +1,1228 @@ +# 2code UI Inventory — Settings, Terminal, Git, Debug, Updater & Dialogs + +Framework-agnostic inventory derived from source code in `/workspace`. Intended for reimplementing the UI without the current frontend stack. + +**Scope:** Settings window, Terminal UI, Git UI, Debug UI, Updater UI, and all remaining modal dialogs/overlays. + +**Out of scope (referenced only where they attach):** Home page, sidebar, file tree, file viewer, command palette, notes editor, onboarding tour. + +--- + +## Table of Contents + +1. [Window Architecture](#1-window-architecture) +2. [Settings Window](#2-settings-window) +3. [Terminal UI](#3-terminal-ui) +4. [Git UI](#4-git-ui) +5. [Debug UI](#5-debug-ui) +6. [Updater UI](#6-updater-ui) +7. [Remaining Dialogs & Overlays](#7-remaining-dialogs--overlays) +8. [Global Toast System](#8-global-toast-system) +9. [Cross-Cutting Platform Differences](#9-cross-cutting-platform-differences) +10. [Keyboard Shortcuts Summary](#10-keyboard-shortcuts-summary) + +--- + +## 1. Window Architecture + +### 1.1 Main Window (`tauri.conf.json`) + +| Property | Value | +|----------|-------| +| Title (internal) | `2code` | +| Visible title bar text | Hidden (`hiddenTitle: true`) | +| Decorations | Enabled | +| Title bar style | **Overlay** (macOS-style integrated title bar) | +| Traffic light position (macOS) | x=16, y=24 | +| Default size | 1440 × 900 px, centered | +| Maximizable | Yes | + +**Layout (main window root):** + +``` +MainWindow +├── StartupUpdateCheck (invisible — side effect only) +├── Horizontal split +│ ├── AppSidebar (left, collapsible) +│ └── Main content area (relative, scrollable card background) +│ ├── Routes (Home / Project detail placeholder) +│ └── TerminalLayer (absolute inset-0 overlay; shown on profile routes) +├── DebugFloat (fixed bottom-right FAB, conditional) +└── WindowControls (Windows only — custom min/max/close) +``` + +**Drag region:** Project top bar uses `data-tauri-drag-region` for window dragging on macOS overlay chrome. + +### 1.2 Settings Window (second webview) + +Created by Tauri command `open_settings_window` when not already open. + +| Property | Value | +|----------|-------| +| Label | `settings` | +| Title | `Settings` | +| URL | `index.html` (same SPA entry; frontend branches on window label) | +| Size | 880 × 640 px (default), min 600 × 420 px | +| Centered | Yes | +| Decorations | System default (no overlay title bar config) | + +**Frontend branch (`main.tsx`):** If `getCurrentWebviewWindow().label === "settings"`, render `SettingsWindow` instead of `App`. Settings window does **not** mount: file watcher, terminal layer, sidebar, or startup profiling sync. + +**Settings URL tabs:** Query param `?tab=` selects settings tab. `open_update_page` opens/reuses settings window with `?tab=about`. + +**Cross-window sync:** Settings edited in the settings window sync to the main window via broadcast store sync (`crossWindowSync.ts`). + +### 1.3 Global Providers (both windows) + +Outer → inner: QueryClient → ThemeProvider → TooltipProvider → BrowserRouter → content → **Toaster** (Sonner toast stack). + +--- + +## 2. Settings Window + +### 2.1 Root: `SettingsWindow` + +``` +SettingsWindow +└── div (full height, card background, foreground text) + └── SettingsPage +``` + +No sidebar, no terminal layer, no file watcher. + +### 2.2 `SettingsPage` — Tab Shell + +**Layout:** Full-height column. + +``` +SettingsPage +├── Tabs (flex-1 column, min-height 0) +│ ├── TabsList (horizontal, margin 20px top, scroll-x if overflow) +│ │ └── 6 × TabsTrigger (icon + label each) +│ └── Scrollable content area (padding 20px, flex-1 overflow auto) +│ └── TabsContent (one visible at a time) +``` + +**Tabs (left → right):** + +| Tab ID | Icon | English label (`en.json`) | +|--------|------|---------------------------| +| `general` | Gear | General | +| `terminal` | Terminal window | Terminal | +| `template` | Code | Terminal Templates | +| `notification` | Bell | Notification | +| `topbar` | Monitor | Top Bar | +| `about` | Info | About | + +**Tab state:** Persisted in URL search param `tab`. Default tab when absent: `general`. Changing to `general` clears the param. + +**Terminal tab split layout:** Two columns with 32px gap: +- **Left column:** max-width ~28rem (`max-w-md`), vertical stack gap 24px — pickers +- **Right column:** flex-1 — live `TerminalPreview` + +--- + +### 2.3 General Tab + +**Container:** Vertical stack, max-width ~28rem, gap 24px. + +#### 2.3.1 Language + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Language` | +| Control | Native select (small) | Options: `English` (value `en`), `中文` (value `zh`) | + +Changing value calls `setAppLocale`. + +#### 2.3.2 Theme (app light/dark) + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Theme` | +| Control | Native select (small) | `System`, `Light`, `Dark` | + +Stored in `ThemeContext` preference (separate from terminal theme). + +#### 2.3.3 BorderRadiusPicker + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Border Radius` | +| Control | Toggle group (small, single-select) | `None`, `Small`, `Medium`, `Large`, `Extra Large` | + +**Store default:** `sm` (Small). + +**Effect:** Sets CSS custom properties `--radius`, `--radius-sm/md/lg/xl` on document root. + +#### 2.3.4 WorktreeSettings + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Default Worktree Directory` | +| Description | Field description | `Used for new profiles when a project does not set worktree_dir. Relative paths are resolved from the project folder.` | +| Row | Horizontal flex | Text input (editable path) + Outline button with folder icon (`Choose Folder`) + Ghost icon button (X, clear) | + +**Placeholder:** `Default: ~/.2code/workspace` + +**Clear button:** Disabled when path empty. Aria-label: `Clear default worktree directory`. + +**Choose Folder:** Opens native directory picker dialog (Tauri). + +**Store default:** empty string. + +#### 2.3.5 Debug Mode + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Debug Mode` | +| Description | `Show backend log events in a floating panel.` | +| Control | Switch (right-aligned horizontal field) | + +**Store default:** `enabled: false`. Persisted. Toggling on starts backend debug log channel. + +#### 2.3.6 Performance Profiling + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Performance Profiling` | +| Description | `Write frontend and backend performance traces until this is turned off or the app exits.` | +| Control | Switch | + +**Store default:** `enabled: false`. Not persisted across restarts (in-memory Zustand only). + +#### 2.3.7 SidebarAppearanceSettings + +| Element | Type | Content | +|---------|------|---------| +| Label | Field label | `Show Project Avatars` | +| Description | `Display project avatars in the left sidebar.` | +| Control | Switch | + +**Store default:** `showProjectAvatars: true`. + +--- + +### 2.4 Terminal Tab + +**Left column (top → bottom):** + +#### TerminalThemePicker + +**When "sync theme" checked:** +- One theme select labeled `Terminal Theme` +- Preview eye button (ghost icon-xs, right of label) + +**When sync unchecked:** +- `Dark Mode Theme` select + preview button +- `Light Mode Theme` select + preview button + +**Theme select options** (display names from `themes.ts`): + +| ID | Display name | +|----|--------------| +| github-dark | GitHub Dark | +| github-light | GitHub Light | +| dracula | Dracula | +| ayu-dark | Ayu Dark | +| ayu-light | Ayu Light | +| solarized-dark | Solarized Dark | +| solarized-light | Solarized Light | +| one-dark | One Dark | +| one-light | One Light | + +**Preview button:** Aria-label `Preview`. Sets parent preview theme override. + +**Sync checkbox row:** Checkbox + label `Use same theme for both modes` + +**Store defaults:** +- `darkTerminalTheme`: `github-dark` +- `lightTerminalTheme`: `github-light` +- `syncTerminalTheme`: `false` + +#### ShellPicker (async — skeleton 70px while loading) + +| Element | Content | +|---------|---------| +| Label | `Default Shell` | +| Select | List of detected shells; default shell marked `(default)`; last option `Custom` | +| Conditional input | Shown when Custom selected — monospace input, placeholder `e.g. /usr/bin/fish` | +| Description | `Used for newly opened terminal tabs.` | + +Shell list from backend `listAvailableShells`. + +#### FontPicker (async — skeleton 70px) + +| Element | Content | +|---------|---------| +| Label | `Terminal Font` | +| Select | Mono fonts by default; each option shows font family name | +| Empty state | Select shows `Unavailable` + description `No system fonts were found on this machine.` | +| Checkbox below | `Show all fonts` (includes non-monospace when checked) | + +**Store defaults:** `fontFamily: "JetBrains Mono"`, `showAllFonts: false` + +**Platform:** `listSystemFonts` — macOS Core Text, Linux/Windows fontdb. + +#### FontSizePicker + +| Element | Content | +|---------|---------| +| Label | `Font Size` | +| Control | Number input | + +**Constraints:** min 10, max 20. **Default:** 13. + +**Right column: TerminalPreview** + +Read-only faux terminal block: +- Background/foreground from selected or auto terminal theme +- Font family/size from terminal settings store +- Padding 12px 16px, 8px border radius, 0.5px border +- Fixed sample lines: `whoami`, `ls`, `echo "Hello, 2code!"` with green `$` prompts +- Blinking block cursor on last empty prompt line + +Preview updates live when font/size/theme eye-preview changes. + +--- + +### 2.5 Template Tab (Global Terminal Templates) + +**Container:** max-width ~42rem (`max-w-2xl`). + +``` +GlobalTerminalTemplatesSettings +├── Header row +│ ├── Title: "Global Templates" (semibold) +│ ├── Description: "These templates are available in every project." +│ └── Button (outline, sm): "Add Template" +├── Empty state OR template list +└── TerminalTemplateDraftDialog (modal) +``` + +**Empty state:** Bordered rounded box, muted text: `No templates yet.` + +**Template list item (each):** +- Bordered rounded row, horizontal flex +- Left: template name (truncate, medium weight) + command preview (monospace, muted, truncate) +- Right: Ghost icon buttons — Edit (pencil), Delete (trash, destructive color) +- Disabled while save pending + +**Store default:** empty templates array (persisted via Tauri storage). + +--- + +### 2.6 Notification Tab + +**Container:** max-width ~28rem, gap 24px. + +#### Enable toggle + +| Label | `Enable Notifications` | +| Description | `Allow this app to send system notifications.` | +| Control | Switch | + +On enable: requests OS notification permission; if denied, toggle reverts. + +**Store default:** `enabled: false` + +#### SoundPicker (async) + +| Label | `Sound` | +| Preview button | Speaker icon, aria-label `Preview` — disabled if notifications off, no sound, or no sounds | +| Select | `None` + system sound names; disabled when notifications off or no sounds | +| Empty | Shows `No system sounds` + description | + +**Store default:** `sound: "Ping"` + +**Platform:** `listSystemSounds` / `playSystemSound` — macOS `/System/Library/Sounds`, Windows `C:\Windows\Media`, Linux XDG dirs. + +--- + +### 2.7 Top Bar Tab (`TopBarSettings`) + +**Container:** max-width ~42rem, gap 24px. + +``` +TopBarSettings +├── Hint text: "Drag controls to reorder or move between areas." +├── DndContext +│ ├── TopBarPreview ("Current Controls") +│ ├── AvailableControls ("Available Controls") +│ └── DragOverlay (floating copy while dragging) +├── Optional app selects (if apps detected) +│ ├── Editor Application → native select +│ └── Terminal Application → native select +└── Button (outline, sm): "Reset to Defaults" +``` + +**Loading:** `Detecting installed apps...` + +**Error:** Shows error message text. + +**TopBarPreview:** Mock bar showing "My Project" + branch icon "main" + draggable active controls area. + +**AvailableControls:** Dashed border drop zone for inactive controls. + +**Drag behavior:** +- Reorder within preview +- Drag from preview to available → remove from bar +- Drag from available to preview → add to bar + +--- + +### 2.8 About Tab (`AboutSettings`) + +**Container:** max-width ~42rem, vertical gap 32px. + +#### App identity section + +| Element | Content | +|---------|---------| +| App icon | 80×80 px, drop shadow, alt "2code" | +| Title | `2code` (2xl semibold) | +| Version badge | `Version {version}` — clickable copies to clipboard; skeleton while loading | +| Tagline | `The Vibe Coding Workstation — A desktop workspace where terminal, AI agents, and Git live together for uninterrupted flow state.` | + +Non-Tauri (web dev): version shows `dev`. + +#### External links row + +Two outline small buttons: +- GitHub icon + `Repository` + external arrow → `https://github.com/AkaraChen/2code` +- Tag icon + `Releases` + external arrow → releases URL + +#### Update section (bordered card) + +**Header bar:** Title `Update` + optional badge: +- If update available: `Update {version} is available` +- If checked and up to date: `Already up to date` (outline muted badge) + +**Body:** +- Switch: `Accept Beta Updates` / `Check prerelease builds from the beta channel.` +- Status text (one of): + - Update available description with current/latest versions + - Release date line: `Released {date}` (locale-formatted) + - Not available / idle descriptions + - Error text (destructive color) if update error +- Buttons: + - `Check for Updates` (outline) — shows spinner while checking + - `Install {version}` (primary) — shown when update available; spinner while downloading + +**Updater settings default:** `acceptBetaUpdates: false` + +#### Contributors + +Heading `Contributors` + clickable card: +- Avatar 36px round +- Name `AkaraChen` +- Subtitle `Project maintainer and primary contributor.` +- Hover: external arrow icon + +#### Footer + +`© {currentYear} AkaraChen` (xs muted) + +--- + +### 2.9 TerminalTemplateDraftDialog (shared modal) + +Used from Global Templates settings (and project templates elsewhere). + +| Property | Value | +|----------|-------| +| Max width | ~32rem (`sm:max-w-lg`) | +| Title icon | Terminal window | +| Title | `Add Template` or `Edit Template` | + +**Fields:** +1. **Template Name** — text input, placeholder `e.g. Dev Server` +2. **Working Directory** — optional (`showCwd` prop); only in project context +3. **Commands** — textarea 8 rows, monospace, placeholder `One command per line…`; description `Runs after the terminal starts. One command per line.` + +**Footer (space-between):** +- Left: `Cancel` (outline) + `Delete` (destructive, edit mode only, spinner if pending) +- Right: `Save` (disabled if name empty or pending) + +--- + +## 3. Terminal UI + +### 3.1 Layer Architecture + +``` +TerminalLayer (absolute inset-0, one visible profile at a time) +├── For each active profile ID: +│ └── div (absolute inset-0; display flex/none) +│ └── ProfileLayout +│ ├── CommandPalette +│ ├── ProjectTopBar (see Git section) +│ ├── ProfileSidebar (files/git/notes — out of scope) +│ └── TerminalTabs +└── TerminalFileLinkPickerDialog (global, one instance) +``` + +**Persistence rule:** Terminals never unmount. Inactive profiles/tabs use CSS `display: none` or `visibility: hidden`. + +**Global shortcuts (TerminalLayer, active profile only):** +- **⌘T** (metaKey + t): Create new terminal tab in active profile +- **⌘W** (metaKey + w): Close active terminal tab + +Note: These check `e.metaKey` only (macOS-oriented). + +--- + +### 3.2 ProfileLayout → Terminal Area + +Below top bar + optional sidebar: + +``` +TerminalTabs (full width/height column) +├── Tab bar row (border-bottom, horizontal scroll) +│ └── TabsList (line variant) +│ ├── Terminal tab triggers (one per session) +│ ├── File tab triggers (one per open file) +│ └── TerminalTemplateMenu ("+ New Terminal") +├── File viewer pane (flex-1, conditional — only when file tab active) +└── Terminal area (flex-1, relative) + ├── Per-tab absolute layers (visibility hidden when inactive) + └── emptyFallback when no tabs +``` + +When file tab active: terminal area `display: none` (but terminals stay mounted). + +--- + +### 3.3 Tab Bar + +**Tab trigger layout class:** max-width 14rem (`max-w-56`), flex-none, left-aligned content. + +#### Terminal tab trigger (each) + +Left → right: +1. **Icon** — 14×14 px: + - Default: terminal window icon + - Agent keyword match (case-insensitive in title): claude, codex, gemini, kimi, cline, openclaw, opencode, qoder → respective brand SVG +2. **Title** — truncated text (from PTY OSC title or default) +3. **AgentStatusDot** (optional) — see §3.8 +4. **Close button** — 16×16 hit target, X icon, aria-label `Close {title}` + +**Drop target:** Terminal tabs accept file-tree drag-drop of paths (writes formatted paths to PTY). + +#### File tab trigger (each) + +1. File type icon (14px) +2. Truncated filename +3. Dirty indicator — 8px filled circle (muted) if unsaved +4. Close button + +Closing dirty file → `UnsavedFileCloseDialog`. + +#### TerminalTemplateMenu (trailing) + +Rendered as last item in tab list. + +**Primary click / Enter / Space on trigger:** Creates default empty terminal (does not open dropdown). + +**Hover-open dropdown** (120ms close delay, non-modal): +- Empty: message `No templates yet.` + hint `Add project templates in Project Settings, or global templates in Settings.` +- **Project Templates** group (if any) — name + optional cwd subtitle +- Separator (if both groups) +- **Global Templates** group (if any) + +Selecting template creates terminal running template commands. + +**Button label:** `New Terminal` + +--- + +### 3.4 Terminal Component (xterm.js host) + +**Outer shell:** +- Flex column, relative, 100% size +- Padding: 8px top, 0 right/bottom, 8px left +- Background: terminal theme background color +- Overflow hidden + +**Inner:** Flex-1 min-size-0 div — xterm mounts here in a 100% wrapper div. + +**Search overlay (when open):** `TerminalSearchBar` positioned absolute top-right inside shell. + +#### xterm visual configuration + +| Setting | Value | +|---------|-------| +| Cursor | Bar, width 4px, blinking | +| Cursor inactive | Outline style | +| Scrollback | 5000 lines | +| Scrollbar | Hidden | +| macOptionIsMeta | false | + +#### TerminalSearchBar + +Fixed overlay: top-right (12px inset), z-index 20. + +| Element | Details | +|---------|---------| +| Search input | width ~14rem, height 28px, autofocus; placeholder `Search terminal` | +| Result count | `No results` or `{current}/{total}` | +| Previous button | Caret up, aria `Previous match` | +| Next button | Caret down, aria `Next match` | +| Close button | X, aria `Close search` | + +**Match highlight colors:** gold/brown inactive, blue active (hardcoded hex). + +**Keyboard in search:** +- Escape → close, refocus terminal +- Enter → next match (Shift+Enter → previous) +- Typing → incremental search + +#### Link handling + +- Web links and file links open confirmation unless Ctrl/Cmd+click bypass +- **TerminalLinkConfirmDialog** for external URLs +- Ambiguous file paths → **TerminalFileLinkPickerDialog** + +#### Agent detection + +Polls every 250ms (2s when tab hidden). Publishes `running` | `waiting` | `idle` to store. Waiting triggers notification sound + optional system notification. + +#### Process exit + +Appends gray line: `[Process exited]` + +--- + +### 3.5 Terminal Keyboard Shortcuts (in-terminal focus) + +| Shortcut | Platform | Action | +|----------|----------|--------| +| ⌘F | macOS | Open search | +| Ctrl+Shift+F | Windows/Linux | Open search | +| ⌘= / ⌘+ | macOS | Increase font size | +| ⌘- | macOS | Decrease font size | +| Ctrl+L | All | Clear screen (+ PTY clear) | +| Ctrl+C | Windows/Linux | Copy selection, or SIGINT if no selection | +| Ctrl+V | Windows/Linux | Paste clipboard to PTY | +| ⌘← / ⌘→ | macOS | Send Home / End | +| Alt+← / Alt+→ | All | Word left / word right | +| Shift+Enter | All | Send newline | + +Selection auto-copies to clipboard with toast **"Text copied"** (hardcoded English, not i18n). + +Font size changes persist to terminal settings store (clamped 10–20). + +--- + +### 3.6 TerminalLinkConfirmDialog + +| Title | `Open Link` (link icon) | +| Description | `Choose where to open this link. Hold Ctrl and click to open directly in the default browser next time.` | +| URL label | `Link` | +| URL value | Monospace, break-all | +| Cancel | `Cancel` | +| Primary split button | `Open in Default Browser` + dropdown caret | +| Dropdown | List installed browsers (`listInstalledBrowsers`); disabled if empty; aria `Open With` | + +--- + +### 3.7 TerminalFileLinkPickerDialog + +| Max width | ~36rem | +| Title | `Choose File` (file icon) | +| Description | `The exact file path was not found. Select one of the matching files to open.` | +| List | Scrollable max 50vh, bordered | + +**Each candidate row (min-height 44px):** +- File icon 16px +- Name (truncate, medium) + relative path (monospace xs muted) +- FileText icon trailing +- Click → opens file in file viewer tab, closes dialog + +--- + +### 3.8 AgentStatusDot + +8px circle (`size-2`), rounded full: + +| Status | Appearance | +|--------|------------| +| `running` | Emerald green + pulsing shadow animation | +| `waiting` | Yellow (`yellow-400`) | +| `completed` | Green (`green-500`) — shown as dismissible notification dot on tab when no active status | + +Dismiss completion: click dot, aria-label `Dismiss completion notification`. + +--- + +### 3.9 TerminalPreview (settings only) + +Static HTML/CSS mock terminal — not a live PTY. See §2.4. + +--- + +## 4. Git UI + +### 4.1 ProjectTopBar + +**Height:** min 44px. **Padding:** platform-dependent right padding (Windows: extra 118px for system buttons). + +**Three zones (horizontal):** + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Left controls] [Center title - absolute centered] [Right controls] │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### Left zone + +- **Expand sidebar** button (only when app sidebar collapsed) — ghost icon, tooltip `Expand sidebar` +- **SidebarModeSwitch** (when sidebar mode props provided) — toggles Files / Git / Notes sidebar + +#### Center zone (pointer-events split) + +- **Project name** — semibold truncate, clickable → reveals worktree path in Finder/file manager (tooltip shows full path) +- **Branch control** — git branch icon + branch name, clickable → opens SwitchBranchDialog + - Default profile + active: live git branch from repo + - Non-default profile: shows `profile.branch_name` static + +#### Right zone + +- Dynamic **top bar controls** from registry (GitHub Desktop, editor launcher, terminal launcher, PR status, etc.) +- **Project settings** gear button (secondary icon) — tooltip `Project Settings` → ProjectSettingsDialog + +**Keyboard shortcuts (active profile):** +- **⌘G / Ctrl+G:** Open Git diff dialog (Changes tab) +- **⌘E / Ctrl+E:** Toggle file tree sidebar + +**Mounted dialogs from top bar:** +- `ProjectSettingsDialog` +- `SwitchBranchDialog` +- `GitDiffDialog` (branch name from live git or profile) + +--- + +### 4.2 SwitchBranchDialog + +| Max width | ~28rem | +| Padding | Header/content split, no outer padding on content wrapper | + +``` +SwitchBranchDialog +├── Header (border-bottom, px-16 py-12) +│ └── Title: "Switch branch" + branch icon +├── Search input (px-12 py-8, h-32, autofocus) +│ └── placeholder: "Search branches…" +└── Scrollable list (max-height min(60dvh, 384px)) + └── BranchRow × N +``` + +**BranchRow contents:** +- Branch icon (muted) +- Branch name (truncate; bold if current) +- Badges (optional): + - `current` (secondary) — current branch, row disabled/selected bg + - `trunk` (outline) + - `used` (amber outline) — branch in use by another profile, disabled +- Ahead/behind counts: green `↑N`, red `↓N` ( monospace 11px) + +**Empty search:** `No branches found` + +**Loading:** Centered spinner + +**Checkout success toast:** `Switched to {branch}` + +--- + +### 4.3 GitDiffDialog (full-screen modal) + +| Size | width `min(88rem, 100vw - 2rem)`, height `min(82dvh, 56rem)` | +| Close button | Hidden in dialog chrome — custom close in header | +| Padding | 0 (full bleed) | + +``` +GitDiffDialog +├── GitDiffHeader +└── GitDiffContent (flex-1 min-height 0) +``` + +#### GitDiffHeader + +Horizontal bar, border-bottom, padding 16×8: + +| Zone | Content | +|------|---------| +| Title | Branch icon + truncated branch name (fallback "main") | +| Center-right | Toggle group `Preview`: `Unified` / `Split` | +| Far right | Ghost icon close button (aria "Close") | + +#### GitDiffContent — Master Layout + +``` +┌──────────────────┬─────────────────────────────────────┐ +│ Sidebar 360px │ Diff pane (flex-1) │ +│ border-right │ │ +│ │ │ +│ [Changes|History] │ File diff viewer OR empty state │ +│ │ │ +│ File/commit list │ │ +│ + commit form │ │ +└──────────────────┴─────────────────────────────────────┘ + [Review Queue FAB if comments exist] +``` + +**Sidebar:** Fixed width 360px, focusable (`tabIndex=0`) for keyboard nav. + +**Sidebar tabs:** +- `Changes` (git diff icon) +- `History` (commit icon) + +--- + +### 4.4 Changes Tab (Sidebar) + +#### Empty state +Centered: `No changes detected` + +#### ChangesFileList + +**Sticky header:** +- Tri-state checkbox (include all/none) — aria `All` +- Text: `{count} changed file(s)` +- Optional maximize button (sidebar mode only) — aria `Open diff view` + +**File rows (`FileListItem`):** +- Optional checkbox (include in commit) +- Basename (truncate) + parent path (xs muted) +- Change badge (letter in colored square): + - `A` green — added + - `D` red — deleted + - `M` blue — modified + - `R` amber — rename +- Active row: muted background +- Double-click → open file in editor tab, close diff dialog +- Right-click → context menu portal (fixed 200px): + - Destructive button: `Discard changes to this file` + +**Keyboard (sidebar focused):** +- ↑/↓ — move selection +- Space — toggle include checkbox (changes tab) +- Enter — drill into commit (history tab) +- Backspace/Escape — back from commit file list (history) + +#### CommitComposer (bottom, border-top) + +| Field | Label | Placeholder | +|-------|-------|-------------| +| Summary | `Summary` | `Describe the changes you're committing` | +| Body | `Description` | `Add an optional extended description` | + +Section title: `Commit` (uppercase xs muted) + +**Buttons (right-aligned):** +- If no changed files but commits ahead: **Push** button with ahead count + upload icon +- Else: **Commit** button (disabled unless ≥1 file included + non-empty summary) + +**Shortcut:** ⌘/Ctrl+Enter in summary/body → commit (if valid) + +**Global shortcut:** ⌘/Ctrl+Enter when no local changes but ahead commits → push + +--- + +### 4.5 Changes Tab (Diff Pane) + +**Component:** `GitDiffPane` with `@pierre/diffs` FileDiff renderer. + +**Font:** Uses terminal font family + size from settings. + +**Empty states:** +- No changes: `No changes detected` +- No selection: `Select a file to view changes` + +**File header bar:** +- Change badge + truncated path (rename shows `old → new`) +- Green `+N` / red `-N` line counts + +**Special views:** +1. **Large diff guardrail** (≥ threshold changed lines, default threshold in utils): + - Title: `Large diff hidden by default` + - Description with counts + - Button: `Load diff anyway` +2. **Rename-only:** Previous path / Current path rows +3. **Binary image:** Side-by-side Before/After preview panes with checkerboard background +4. **Normal diff:** Unified or split per header toggle + +**Review comments (changes pane only):** +- Line click/drag selection opens floating composer anchored to selection +- Composer: "Comment on {range}", textarea, "Add to queue" button +- Hardcoded English strings in composer UI + +**Review Queue FAB:** Bottom-right when comments exist — button `Review Queue` + count badge (hardcoded English label on FAB). + +--- + +### 4.6 History Tab + +#### Commit list (initial) + +Scrollable rows: +- Commit message (1 line clamp) +- Hash (mono) + author + relative time (`just now`, `Nm ago`, etc. — hardcoded English) +- Stats: N file(s), +insertions, -deletions + +Empty: `No commits found` + +#### Commit drill-in + +**Header:** Back arrow (aria `Back to commit list`) + message + hash + +**File list:** Same `FileListItem` without checkboxes + `{count} changed file(s)` + +Empty files: `No file changes` + +**Diff pane:** Shows selected commit file diff (no review comments). + +--- + +### 4.7 GitReviewQueueDialog + +| Size | width `min(56rem, 100vw-2rem)`, max-height 80vh | + +**Header:** `Review Queue` (chat icon) + +**Body:** Scrollable list of comment cards, each: +- File path (mono semibold) +- Range badge + "Selected diff" (hardcoded) +- Mini diff preview (max-height 128px) +- "Comment" label + editable textarea +- Delete button (destructive icon) + +**Footer:** +- `Copy` (outline) — hardcoded English +- `Copy and clear all` (destructive) — hardcoded English + +Success toasts: `Review comments copied` / `Review comments copied and cleared` + +--- + +### 4.8 SidebarGitPanel (compact git in profile sidebar) + +Same as Changes sidebar subset: +- `ChangesFileList` with maximize button → opens full GitDiffDialog +- `CommitComposer` below +- No history tab in sidebar mode + +Tooltips on file paths disabled in compact mode. + +--- + +## 5. Debug UI + +### 5.1 Enabling + +**Settings → General → Debug Mode** switch. + +When enabled: +- Backend debug log channel starts +- `DebugFloat` FAB appears + +### 5.2 DebugFloat + +| Property | Value | +|----------|-------| +| Visibility | Only when debug mode enabled | +| Position | Fixed, right 64px, bottom 16px, z-index 50 | +| Shape | Circular icon button | +| Icon | Wrench | +| Aria-label | `Debug Log` | + +Click → opens DebugLogDialog. + +### 5.3 DebugLogDialog + +| Max width | ~32rem (`sm:max-w-lg`) | +| Max height | 70vh | +| Title | `Debug Log` | + +``` +DebugLogDialog +├── Search input (flex-1) — placeholder "Search logs..." +├── Clear button (trash icon, aria "Clear") +├── Log list (scrollable, flex-1) +│ └── LogRow × N +└── Footer count: "{filtered} / {total}" (xs muted, right) +``` + +**LogRow columns:** +1. Timestamp `HH:MM:SS.mmm` +2. Level badge: ERROR (destructive), WARN (secondary), INFO (default) +3. Source (muted) +4. Message (break-all) + +**Auto-scroll:** Sticks to bottom unless user scrolls up (>40px from bottom). + +**Empty filtered:** `No log entries yet` + +**Search:** Filters message, source, level (case-insensitive). + +### 5.4 Debug Keyboard Shortcut + +**⌘⇧D** (macOS) / **Ctrl+Shift+D** (others): Toggle debug panel open/closed (only if debug mode enabled; otherwise no-op). + +--- + +## 6. Updater UI + +### 6.1 StartupUpdateCheck + +Invisible component mounted in main `App`. On mount: +1. Silent `checkForUpdate` +2. If update available (once per app session): Sonner **info** toast + +**Toast contents:** +- Title: `Update {version} is available` +- Description: `Current version {currentVersion}; latest version {version}.` +- Duration: 12 seconds +- Action button: `Open Update Page` → opens settings window on About tab + +Toast id: `update-available` (deduped). + +### 6.2 About Tab Update Section + +See §2.8. Full manual check/install flow with beta toggle. + +**Toasts from About actions:** +| Action | Toast | +|--------|-------| +| Copy version | `Version copied to clipboard` | +| Check — update found | info: update available title + description | +| Check — up to date | info: already up to date | +| Check — error | error: update check failed + message | +| Install — error | error: update failed + message | + +### 6.3 Updater Backend Config + +- Endpoint: GitHub releases `latest.json` +- Beta channel controlled by `acceptBetaUpdates` setting + +--- + +## 7. Remaining Dialogs & Overlays + +### 7.1 CreateProjectDialog + +| Title | `Create Project` (folder-plus icon) | + +**Flow:** +1. Initial: dashed bordered drop zone — folder icon + `Choose Folder` +2. After folder selected: folder path in monospace box + small `Choose Folder` edit button +3. Project name field — label `Project Name`, placeholder `Optional. Leave empty to use folder name` +4. Dynamic hint text (one of three i18n hints based on state) + +**Footer:** `Cancel` | `Create` (disabled without folder or while pending) + +Enter in name field submits. + +--- + +### 7.2 DeleteProjectDialog + +| Title | `Delete Project` (trash icon) | +| Body | `Are you sure you want to delete this project? This action cannot be undone.` | +| Footer | `Cancel` | `Delete` (destructive) | + +--- + +### 7.3 RenameProjectDialog + +| Title | `Rename` (pencil icon) | +| Field | `New Name` | +| Footer | `Cancel` | `Rename` (disabled if empty or unchanged) | + +Autofocuses rename input on open. + +--- + +### 7.4 ProjectSettingsDialog + +| Max width | ~32rem | +| Title | `Project Settings` (gear icon) | + +**Tabs:** +1. **Scripts** (code icon) +2. **Templates** (terminal icon) + +**Scripts tab fields:** +| Field | Label | Description | +|-------|-------|-------------| +| Worktree dir | `Worktree Directory` | Overrides global default… | +| Init script | `Init Script` | Injected into every new terminal session | +| Setup script | `Setup Script` | Runs when creating profile | +| Teardown script | `Teardown Script` | Runs when deleting profile | + +All script fields: monospace textarea 4 rows, placeholder `One command per line…` + +**Templates tab:** `ProjectTemplatesEditor` — same list/edit pattern as global templates but with cwd field in draft dialog (`showCwd=true`). + +**Footer:** `Cancel` | `Save` + +Loading: centered spinner min-height 200px. + +--- + +### 7.5 CreateProfileDialog + +| Title | `New Profile` (branch icon) | +| Field | `Branch Name`, placeholder `Optional. Leave empty to auto-generate…` | +| Footer | `Cancel` | `Create` | + +--- + +### 7.6 DeleteProfileDialog + +| Title | `Delete Profile` (trash icon) | +| Body | `Are you sure you want to delete this profile? The git worktree will be removed.` | + +**Conditional blocks while checking git:** +- Spinner + `Checking this profile's Git status…` +- Warning alert with aggregated diff/commit warnings +- Error alert if git check failed + +**Footer:** `Cancel` | `Delete` or `Delete Anyway` (if risky) + +--- + +### 7.7 UnsavedFileCloseDialog + +| Title | `Close Unsaved File?` (warning icon) | +| Body | `{file} has unsaved changes. Closing it will discard those changes.` | +| Footer | `Cancel` | `Discard Changes` (destructive) | + +Triggered from terminal tab bar when closing dirty file tab. + +--- + +### 7.8 Context Menu Overlay (ChangesFileList) + +Not a dialog — fixed portal at cursor: +- 200px wide popover +- Single destructive action: `Discard changes to this file` +- Closes on outside click, Escape, blur, resize + +--- + +### 7.9 Floating Review Comment Composer (GitDiffPane) + +Fixed-position popover (not modal) anchored to selected diff lines: +- Header: comment range + cancel +- Textarea +- "Add to queue" button + +--- + +### 7.10 Command Palette (attached to profile) + +Overlay search for files — triggered **⌘K / Ctrl+K**. Out of detailed scope but exists in `ProfileLayout`. + +--- + +## 8. Global Toast System + +**Component:** Sonner `Toaster` in both windows. + +**Used for:** Update notifications, git operations, clipboard confirmations, review queue copy, agent-adjacent feedback, file tree errors, etc. + +Standard patterns: +- `toast.success(title, { description })` +- `toast.error(title, { description })` +- `toast.info(title, { description, action: { label, onClick } })` + +--- + +## 9. Cross-Cutting Platform Differences + +| Feature | macOS | Windows | Linux | +|---------|-------|---------|-------| +| Main window title bar | Overlay + traffic lights at (16,24) | Standard decorations; extra topbar right padding 118px for window controls | Standard decorations | +| Custom window controls | No (traffic lights in overlay) | Yes (`WindowControls` component) | No | +| Settings shortcut | ⌘, | Ctrl+, | Ctrl+, | +| Debug toggle | ⌘⇧D | Ctrl+Shift+D | Ctrl+Shift+D | +| Terminal new/close tab | ⌘T / ⌘W (metaKey only in code) | Same binding checks metaKey | Same | +| Terminal search | ⌘F | Ctrl+Shift+F | Ctrl+Shift+F | +| Font listing | Core Text | fontdb | fontdb | +| Sound listing | `/System/Library/Sounds` | `C:\Windows\Media` | XDG sound dirs | +| Notification permission | Tauri notification plugin prompt on enable | Same | Same | +| File reveal | "Reveal in Finder" tooltip on project name | Platform file manager via backend | Platform file manager | + +**Settings window:** Uses standard system title bar with title text "Settings" (not overlay style). + +**Browser list for links:** From `listInstalledBrowsers` when link confirm opens. + +--- + +## 10. Keyboard Shortcuts Summary + +| Shortcut | Context | Action | +|----------|---------|--------| +| ⌘, / Ctrl+, | Global (main window) | Open settings window | +| ⌘⇧D / Ctrl+⇧D | Global | Toggle debug log dialog | +| ⌘T | Active profile | New terminal tab | +| ⌘W | Active profile | Close active terminal tab | +| ⌘G / Ctrl+G | Active profile top bar | Open git diff dialog | +| ⌘E / Ctrl+E | Active profile top bar | Toggle sidebar | +| ⌘K / Ctrl+K | Active profile | Command palette | +| ⌘F | Terminal focus (macOS) | Terminal search | +| Ctrl+Shift+F | Terminal focus (Win/Linux) | Terminal search | +| ⌘=/⌘+ / ⌘- | Terminal (macOS) | Font size | +| Ctrl+L | Terminal | Clear screen | +| ⌘←/→ | Terminal (macOS) | Home/End to PTY | +| Alt+←/→ | Terminal | Word motion | +| Shift+Enter | Terminal | Newline to PTY | +| ↑/↓/Space/Enter | Git diff sidebar | Navigate/toggle/select | +| ⌘/Ctrl+Enter | Git commit form | Commit | +| ⌘/Ctrl+Enter | Git diff (no changes, ahead) | Push | +| Escape | Terminal search / git contexts | Close/back | + +--- + +## Appendix A — Persisted Settings Defaults + +| Store | Key | Default | +|-------|-----|---------| +| terminalSettings | fontFamily | JetBrains Mono | +| terminalSettings | fontSize | 13 | +| terminalSettings | darkTerminalTheme | github-dark | +| terminalSettings | lightTerminalTheme | github-light | +| terminalSettings | syncTerminalTheme | false | +| terminalSettings | showAllFonts | false | +| terminalSettings | defaultShell | from `DEFAULT_TERMINAL_SHELL` | +| themeStore | borderRadius | sm | +| notificationStore | enabled | false | +| notificationStore | sound | Ping | +| sidebarSettings | showProjectAvatars | true | +| worktreeSettings | defaultWorktreeDir | "" (empty → ~/.2code/workspace) | +| updaterSettings | acceptBetaUpdates | false | +| debugStore | enabled | false | +| terminalTemplates | templates | [] | + +--- + +## Appendix B — Hardcoded Non-i18n Strings + +These appear in UI without `en.json` entries: + +| Location | String | +|----------|--------| +| Terminal.tsx | Toast: "Text copied" | +| GitDiffContent.tsx | FAB: "Review Queue" | +| GitReviewQueueDialog | "Copy", "Copy and clear all", "Selected diff", "Comment", "Comment on …", "Add to queue" | +| GitDiffPane comment composer | "Add to queue", "Cancel review comment" | +| GitDiffHeader | Close aria-label: "Close" | +| CommitList | Relative time: "just now", "Nm ago", "file/files" | +| TerminalTabs | "Dismiss completion notification", "Close {title}" | +| TopBarPreview | Mock text: "My Project", "main" | + +--- + +*Document generated from codebase inspection. Branch: settings/terminal/git/debug/updater components as specified in exploration task.* diff --git a/docs/ui-inventory.md b/docs/ui-inventory.md new file mode 100644 index 00000000..552e8562 --- /dev/null +++ b/docs/ui-inventory.md @@ -0,0 +1,977 @@ +# 2code UI 完整清单(换框架重写规格) + +这份文档是 2code 桌面端 **全部可见 UI 的框架无关规格**。目标:之后用任何 UI 技术栈重写时,不必再读 React / Tailwind / shadcn 源码,也能按相同产品形态还原窗口、页面、组件树、排布、尺寸、交互、空态、错误态和文案。 + +**审计日期:** 2026-09-03 +**对照代码:** 主窗口 `src/App.tsx`、`src/layout/*`、`src/features/*`、`src/components/ui/*`、`src/app.css`、`src-tauri/tauri.conf.json`、`src-tauri/src/handler/window.rs`、`messages/en.json` + +**更细的 className / 像素对照(英文附录):** + +| 附录 | 覆盖面 | +|------|--------| +| [sidebar-ui-inventory.md](./sidebar-ui-inventory.md) | 应用侧栏、项目分组、头像、Profile 次级侧栏原语 | +| [ui-inventory-home-project.md](./ui-inventory-home-project.md) | 首页、Profile 布局、文件树、文件查看器、命令面板、项目/Profile 对话框 | +| [ui-inventory-settings-terminal-git-debug-updater.md](./ui-inventory-settings-terminal-git-debug-updater.md) | 设置窗、终端、Git 大对话框、调试、更新器 | + +本文是 **主规格**:先读本文,再按需要查附录。文中尺寸、文案、树结构均来自源码,不 invent。 + +--- + +## 0. 怎么用这份文档重写 + +重写时按「窗口 → 区域 → 组件 → 状态」还原,不要按当前 React 组件名还原。 + +1. **先还原窗口壳**:两个 Webview、原生标题栏、拖拽区、Windows 自定义按钮。 +2. **再还原主窗三分区**:应用侧栏 | 主内容 | 持久终端层。 +3. **再还原 Profile 工作区**:顶栏 + 次级侧栏(Files/Git/Notes)+ 统一标签栏 + 终端/文件查看器。 +4. **最后还原叠加层**:对话框、命令面板、右键菜单、Toast、调试 FAB、新手引导。 + +**不可破坏的产品不变量:** + +- 终端实例 **不能因切标签/切路由而销毁**。隐藏用 CSS `display:none` / `visibility:hidden`,不要条件卸载。 +- 文件树在 Files/Git/Notes 切换时也 **保持挂载**(`display:none`),以免丢失展开/选中状态。 +- 设置是 **独立窗口**,不是主窗内路由页。 +- `Cmd+,` 开设置;`Cmd+Shift+D` 开调试面板;`Cmd+K` 开文件搜索;`Cmd+T`/`Cmd+W` 开/关终端标签。 +- 文案走 i18n(英/中)。品牌字 `"2Code"` 硬编码,不要翻译。 + +--- + +## 1. 产品表面总图 + +2code 是本地优先的「项目 + Profile(git worktree)+ 持久 PTY 终端」工作站。用户看到的全部表面: + +``` +操作系统 +├── 主窗口 "2code" 1440×900 叠加标题栏 +│ ├── 应用侧栏(项目列表) +│ ├── 主内容 +│ │ ├── 首页(仅无项目时停留) +│ │ └── Profile 工作区(有项目后的主界面) +│ │ ├── 顶栏 +│ │ ├── 次级侧栏:Files | Git | Notes +│ │ └── 统一标签栏 + 终端 / 文件查看器 +│ ├── 叠加层:对话框、命令面板、右键菜单、Toast +│ ├── 调试扳手按钮(可选) +│ └── Windows 右上角 min/max/close +│ +└── 设置窗口 "Settings" 880×640 + └── 六个标签:General / Terminal / Templates / Notification / Top Bar / About +``` + +**没有的表面:** 登录、账号、云同步、独立「项目列表页」(有项目会立刻跳进第一个默认 Profile)、移动端布局、汉堡菜单。 + +--- + +## 2. 窗口与原生铬 + +### 2.1 主窗口 + +| 项 | 值 | +|----|----| +| 内部 title | `2code` | +| 可见标题文字 | 隐藏(`hiddenTitle: true`) | +| 装饰 | 系统 decorations 开 | +| 标题栏样式 | **Overlay**(内容顶到标题栏下) | +| macOS 红绿灯 | 左上,坐标 **x=16, y=24** | +| 默认尺寸 | **1440 × 900**,居中 | +| 可最大化 | 是 | + +因为 overlay 标题栏,应用必须自己留出红绿灯和拖拽区: + +- 应用侧栏 **header** 标 `data-tauri-drag-region`。macOS 上 `padding-top: 32px`(`pt-8`),其它平台 `8px`(`pt-2`)。 +- 首页 header、Profile 顶栏也是拖拽区。 +- 拖拽区内的按钮/链接必须 `no-drag`,否则点不到。 +- 应用侧栏折叠后,macOS 顶栏左侧再加 `padding-left: 84px`,避开红绿灯。 +- Windows 顶栏右侧 `padding-right: 118px`,避开自定义窗口按钮。 + +### 2.2 设置窗口 + +| 项 | 值 | +|----|----| +| Webview label | `settings` | +| 标题 | `Settings` | +| URL | 同一个 `index.html`;前端看 label 决定渲染 `SettingsWindow` 而不是 `App` | +| 默认尺寸 | **880 × 640** | +| 最小尺寸 | **600 × 420** | +| 居中 | 是 | +| 装饰 | 系统默认(**不是** overlay) | + +打开方式: + +- 侧栏底部 Settings +- `Cmd+,` / `Ctrl+,` +- 更新 Toast 的 “Open Update Page” → 复用同一窗口并切到 About(`?tab=about`) + +已存在则 `show()` + `focus()`,不新建。设置窗 **不** 挂文件监视、终端层、应用侧栏、启动性能同步。设置项通过跨窗口 broadcast 同步回主窗。 + +### 2.3 Windows 自定义窗口按钮 + +仅 Windows 渲染。`fixed top-0 right-0`,高 **28px**(`h-7`),从左到右: + +1. Minimize — 减号,12px,hover 灰底 +2. Maximize / Restore — 未最大化是方框,最大化是「两份重叠」图标 +3. Close — X,hover **#c42b1c** 白字,active **#b32717** + +每个按钮 **28×36**(`h-7 w-9`),`no-drag`。 + +### 2.4 根布局(主窗) + +``` +html/body/#root = 100% × 100%,overflow:hidden +App +├── StartupUpdateCheck (无 DOM,启动时可能弹 Toast) +├── 横向 flex,flex-1,min-height:0 +│ ├── AppSidebar 或 SidebarSkeleton / SidebarError +│ └── main(relative, flex-1, overflow-y:auto, 背景 --card) +│ ├── Routes +│ │ ├── / HomePage +│ │ ├── /projects/:id/profiles/:profileId ProjectDetailPage +│ │ └── * 重定向 / +│ └── TerminalLayer(absolute inset 0 覆盖层) +├── DebugFloat(条件) +└── WindowControls(仅 Windows) +``` + +整窗背景 `--background`,文字 `--foreground`。侧栏背景 `--sidebar`,主区 `--card`。 + +**全局交互基调:** `body` 禁止文本选择、默认箭头光标。只有 `input` / `textarea` / `contenteditable` / `.xterm` / Markdown 编辑器允许选字。按钮、链接、`[data-sidebar-item]` 也是默认光标(不是 pointer),只有少数明确写了 `cursor-pointer` 的标题/分支例外。 + +--- + +## 3. 设计系统(视觉合同) + +### 3.1 字体 + +| 用途 | 字体 | +|------|------| +| UI 正文 | Inter Variable → 系统栈(-apple-system, Segoe UI, Roboto…) | +| 标题 token | Geist Variable(`--font-heading`) | +| 等宽 / 终端 / 代码 / Markdown | 设置里的终端字体,CSS 变量 `--font-mono`,默认 JetBrains Mono 栈 | +| 抗锯齿 | `-webkit-font-smoothing: antialiased`,`text-rendering: optimizeLegibility` | + +Phosphor 图标默认 **1em、duotone**。少数地方改 `weight="regular"`。 + +### 3.2 颜色 token(oklch) + +亮色 `:root`: + +| Token | 值 | 用途 | +|-------|-----|------| +| `--background` | `oklch(1 0 0)` | 窗体底 | +| `--foreground` | `oklch(0.145 0 0)` | 主文字 | +| `--card` | `oklch(1 0 0)` | 主内容区 | +| `--popover` | `oklch(1 0 0)` | 菜单/对话框/Toast | +| `--primary` | `oklch(0.205 0 0)` | 主按钮(近黑) | +| `--muted` | `oklch(0.97 0 0)` | 浅底、hover | +| `--muted-foreground` | `oklch(0.556 0 0)` | 次要文字 | +| `--destructive` | `oklch(0.577 0.245 27.325)` | 危险操作 | +| `--border` / `--input` | `oklch(0.922 0 0)` | 边框 | +| `--ring` | `oklch(0.708 0 0)` | 焦点环 | +| `--radius` | `0.625rem`(10px) | 基础圆角 | +| `--sidebar` | `oklch(0.985 0 0)` | 侧栏底(比主区略灰) | +| `--sidebar-accent` | `oklch(0.97 0 0)` | 侧栏 hover/选中 | +| `--app-focus-ring` | 系统 `Highlight` | 自定义焦点 | + +暗色 `.dark`:背景 `oklch(0.145)`,卡片/弹出层 `oklch(0.205)`,主色反转成浅色,边框半透明白。侧栏暗色底与卡片同级。 + +**语义色(不走 token):** + +- Git 增加:`text-green-500` / `text-green-600` +- Git 删除:`text-red-500` / `text-red-600` +- Agent waiting:`bg-yellow-400` +- Agent completed:`bg-green-500` +- Agent running:`bg-emerald-400` + 1.4s 脉冲光晕 +- 分支 “used” 徽章:琥珀底/字 +- Windows 关闭按钮:`#c42b1c` + +焦点环优先用系统 `Highlight` / `HighlightText`(选择色也是)。 + +### 3.3 圆角档位 + +设置里的 Border Radius 改 `--radius`。派生: + +| 档 | 显示名 | 相对 `--radius` | +|----|--------|-----------------| +| none | None | 0 | +| small | Small | ×0.6 | +| medium | Medium | 默认 0.625rem | +| large | Large | ×1.4 | +| extra large | Extra Large | ×1.8 | + +常用:按钮 `rounded-lg`,对话框 `rounded-xl`,头像 `rounded-md`,菜单 `rounded-lg`。 + +### 3.4 按钮规格 + +| size | 高 | 用途 | +|------|----|------| +| default | 32px(`h-8`) | 对话框主操作 | +| xs | 24px | 紧凑(改文件夹、重试) | +| sm | 28px | 次要 | +| lg | 36px | 少用 | +| icon | 32×32 | 顶栏齿轮、调试 FAB | +| icon-xs | 24×24 | 搜索条箭头 | +| icon-sm | 28×28 | 对话框关闭 | + +| variant | 外观 | +|---------|------| +| default | 实心 primary | +| outline | 边框 + 白底,hover muted | +| secondary | 浅底 | +| ghost | 无底,hover muted | +| destructive | 浅红底 + 红字(不是大红实心) | +| link | 下划线链接 | + +禁用:`opacity: 50%`,不可点。激活时轻微下移 1px。 + +### 3.5 标准对话框壳 + +默认对话框: + +- 遮罩:全屏 `bg-black/10`,支持 backdrop 时轻微模糊,z-index 50 +- 面板:水平垂直居中,默认 `max-w-sm`,内边距 16px,`rounded-xl`,`bg-popover`,细环 `ring-foreground/10` +- 打开:淡入 + 缩放到 95% +- 右上角默认有 ghost 关闭按钮(Git 大对话框关掉这个默认按钮,自己画关闭) +- 结构:Title(常带 16px 图标)→ 正文 gap 12–20px → Footer 右对齐(Cancel outline + 主按钮) + +标准小对话框(创建/删除/重命名/未保存关闭)都用这套壳。Git Diff 是例外:`min(88rem, 100vw-2rem)` × `min(82dvh, 56rem)`,无内边距。 + +### 3.6 Toast + +Sonner,跟主题走。图标 16px Phosphor regular:成功勾、信息 i、警告三角、错误叉、加载转圈。背景 `--popover`。启动更新 Toast 持续 **12 秒**,带 “Open Update Page” 动作。 + +### 3.7 空状态 + +居中 `Empty`:上方圆形图标容器 → 标题 → 灰色说明 → 可选操作按钮。用于:无项目、无终端、设置/模板空列表。 + +### 3.8 加载 / 错误 + +| 表面 | 加载 | 错误 | +|------|------|------| +| 应用侧栏 | 250px 宽骨架:一条标题 + 半宽标签 + 三条缩进行 | 同宽栏,红标题 “Something went wrong” + 消息 + Try again | +| 主页面 | 左上骨架:48×192 标题 + 两行 | 同样错误栈,padding 32px | +| 面板内部 | 居中 Spinner 16px | 居中错误栈 | +| 行内 | — | 左红标题+截断消息,右 xs Try again | +| 对话框体 | Spinner | 最小高 200px 居中错误栈 | + +### 3.9 Agent 状态点 + +**8×8** 圆点,不可聚焦。 + +| 状态 | 颜色 | 动画 | +|------|------|------| +| waiting | 黄 `#facc15` | 无 | +| running | 翠绿 | 1.4s 脉冲光晕(`prefers-reduced-motion` 时关掉) | +| completed | 绿 `#22c55e` | 无 | + +出现位置:侧栏默认 Profile / 非默认 Profile、终端标签。完成点可点消失(仅标签栏)。 + +--- + +## 4. 应用侧栏(左列) + +### 4.1 尺寸与折叠 + +| 常量 | 值 | +|------|-----| +| 默认宽 | **250px** | +| 最小 / 最大 | **220 / 420** | +| 键盘步进 | 16px | +| 持久化 | localStorage `app-sidebar-width`(宽、是否折叠、折叠的分组 id) | + +折叠时侧栏 **整棵不渲染**。展开按钮只出现在 Profile 顶栏左侧(首页折叠后没有展开钮)。 + +右缘有 **8px** 宽竖直 resize 热区,中线 1px。拖时中线变 `foreground/30`,hover/焦点时显示 border 色。 + +### 4.2 结构(上 → 下) + +``` +Sidebar(role=navigation, aria-label="Side navigation") +├── Header(拖拽区) +│ ├── 左:不可点品牌 "2Code"(semibold) +│ └── 右:折叠按钮 SidebarSimple 图标 +├── Content(竖向滚动,stable scrollbar gutter) +│ ├── [无项目] Home 行(房子图标 + "Home") +│ ├── [有置顶] 分组 "Pinned" +│ │ └── 项目行… +│ └── 分组 "Projects" +│ ├── 标签右侧:铅笔(排序)+ 加号(新建项目)#add-project-button +│ └── 顶层条目:项目 或 分组 +└── Footer + └── Settings 行(齿轮 + "Settings") +``` + +无项目时 Content 里只有 Home。有项目时 Home 不出现(首页会立刻跳走)。 + +键盘:在侧栏内 ↑/↓ 在所有 `[data-sidebar-item]` 间循环。 + +### 4.3 普通模式:项目行 + +一行从左到右: + +1. **头像 16×16**(可在设置关掉) + - 有 GitHub avatar URL:封面图 + - 失败/无图:侧栏 accent 底 + 项目名第一个大写字母,10px + - 空名:`?` +2. **项目名** medium、截断 +3. 右侧动作: + - **只有默认 Profile:** hover/焦点时显示 `+`(新建 Profile);平时若有 agent 状态则显示状态点(hover 时点隐藏、`+` 出现) + - **有额外 Profile:** 展开/折叠 caret(默认展开) + +点击项目名 → 进默认 Profile。 + +**右键菜单:** + +1. Add to Project Group ▸ + - 已有分组列表(当前分组打勾、禁用) + - 若已在分组:Remove from Project Group + - Create Project Group,或直接出现输入框(无分组时) + - 输入占位 `e.g. Work`,Enter 创建并加入,Esc 取消 +2. Project Settings +3. Rename +4. — 分隔 — +5. Delete Project(destructive) + +### 4.4 展开后的 Profile 子列表 + +缩进子菜单: + +1. 第一行永远是 **Default**(终端窗图标 + 文案 “Default” + 可选状态点) +2. 其余 Profile:Git 分支图标 + `branch_name`(溢出出 tooltip)+ 状态点 +3. 最后一行:`+` + “New Profile” + +非默认 Profile 右键只有 **Delete Profile**(destructive)。 + +### 4.5 项目分组 + +分组行:caret + 分组名 + 右侧数字徽章(项目数)。点击折叠。展开用 180ms 高度/透明度动画(`[0.22,1,0.36,1]`),尊重 reduced motion。 + +分组内项目与顶层项目同一套行/菜单。 + +### 4.6 排序模式 + +点铅笔进入。项目/分组变成可拖行: + +- 左:六点拖手(`cursor-grab`) +- 中:头像或文件夹图标 + 名 +- 右:星标钉/取消钉 + +分区: + +1. **Pinned** — 已钉项目 + 虚线投放区 “Drop here to pin” +2. **Projects** — 顶层项目与分组交错;分组下再嵌套项目 + “Drop project into folder”;底部 “Drop here to unpin or move out” + +保存中禁用拖拽。标签右侧铅笔换成 **勾**(完成)+ 加号。拖拽中行透明度 0.45,背景 sidebar-accent。 + +### 4.7 侧栏骨架 / 错误 + +宽固定 250px,右边框,`bg-muted/40`,内边距 16px。错误时居中错误栈。 + +--- + +## 5. 首页 + +有项目时 **立刻 replace 导航** 到第一个项目的默认 Profile,用户几乎看不到首页内容。 + +无项目时: + +``` +HomePage(满高) +├── header 高 52px,底边框,左右 padding 20px,拖拽区 +│ ├── Folder 图标 16px muted +│ └── "Home" semibold 14px +├── 剩余高度居中 Empty +│ ├── FolderPlus 图标 +│ ├── "No projects yet" +│ └── "Create your first project from the sidebar to get started." +└── TourOnboarding(无 React DOM) +``` + +新手引导:300ms 后用 driver.js 指向侧栏 `#add-project-button`。气泡在右侧、上对齐。标题 “Create your first project”,说明 “Click here to add a project folder and get started.” 只有关闭按钮。点加号会销毁引导。 + +--- + +## 6. Profile 工作区(主界面) + +### 6.1 谁在画这块 UI + +两条路径,外观必须一致: + +| 条件 | 谁渲染 ProfileLayout | +|------|----------------------| +| 该 Profile **没有任何** 终端标签和文件标签 | `ProjectDetailPage` 画空态 CTA | +| 该 Profile **至少有一个** 标签 | `TerminalLayer` 用 absolute overlay 画完整工作区;`ProjectDetailPage` 返回 null | + +`TerminalLayer` 为 **每一个曾经开过标签的 Profile** 各挂一份 `ProfileLayout`,非当前路由的用 `display:none`。切项目/Profile 不卸载终端。 + +无效 project/profile → 回默认 Profile 或 `/`。 + +### 6.2 布局 + +``` +ProfileLayout 竖向满高 +├── CommandPalette(始终挂着,关时不可见) +├── 底边框 +│ └── ProjectTopBar 最小高 44px +└── 横向 flex-1,min-width/height 0 + ├── ProfileSidebar 默认 208px,可 180–560 + └── 主列 flex-1 + └── TerminalTabs + ├── 标签栏(终端标签 + 文件标签 + 新建) + ├── 文件查看器(仅文件标签激活时) + └── 终端层(永不卸载,文件标签激活时 display:none) +``` + +次级侧栏关时宽度动画到 0,`pointer-events:none`,`aria-hidden`。弹簧:stiffness 320, damping 34, mass 0.9。宽度存在 localStorage `file-tree-panel`。 + +--- + +## 7. 顶栏(ProjectTopBar) + +三区叠在同一条 **最小 44px** 高的条上,底对齐,左右 padding 16px,上 4px 下 6px,整条拖拽区。 + +``` +[ 左:展开侧栏? + Files/Git/Notes ] [ 绝对居中:项目名 + 分支 ] [ 右:可配置控件 + 齿轮 ] +``` + +居中块 `pointer-events:none`,内部按钮再打开;左右各留 128px(`px-32`)以免和两侧重叠。 + +### 7.1 左 + +1. 应用侧栏折叠时:ghost icon 按钮,SidebarSimple,tooltip “Expand sidebar” +2. **SidebarModeSwitch** — 高 28px 的三段 tab + - Files:FolderSimple 14px + - Git:GitBranch 14px + 可选 `+N` 绿 / `-M` 红 + - Notes:Note 14px + - 只有图标,文字在 tooltip + - 侧栏关闭时三段都未选中 + - **再点当前模式 = 关闭侧栏**;点其它模式 = 切模式并打开 + +快捷键(仅当前 Profile):`Cmd/Ctrl+E` 开关侧栏,`Cmd/Ctrl+G` 开 Git Diff 大对话框。 + +### 7.2 中 + +- **项目名** semibold,可点,tooltip 显示 worktree 绝对路径;点击在系统文件管理器中显示该目录 +- **分支**:Git 图标 + 名 + - 默认 Profile:拉当前 git 分支;非活动 overlay 不拉 + - 非默认 Profile:显示 `profile.branch_name` + - 点击打开 Switch Branch 对话框 + - muted,hover 变主色 + +### 7.3 右 + +用户在设置 Top Bar 里配置的控件(只显示本机已安装的): + +| id | 外观 | 行为 | +|----|------|------| +| `github-desktop` | GitHub 标 | 用 GitHub Desktop 打开当前 worktree | +| `editor` | Code 标 | 用所选编辑器打开(VS Code / Cursor / Windsurf / Zed / Sublime) | +| `terminal` | 终端窗标 | 用所选终端打开(Ghostty / iTerm2 / kitty / Warp) | +| `pr-status` | PR 标 + 状态 | Checking / No PR / Open / Draft / Merged / Closed;tooltip 带号和标题 | + +最右固定 **齿轮** secondary icon,开 **项目设置对话框**(不是应用设置窗)。 + +--- + +## 8. 次级侧栏三模式 + +右缘 8px resize,aria “Resize sidebar”,键盘 ±16 / Home / End。 + +### 8.1 Files — 文件树 + +Pierre FileTree,compact,完整图标,粘性文件夹,默认全收起。 + +| 视觉 | 值 | +|------|-----| +| 字号 | 13px | +| 缩进 | 12px / 级 | +| 行左右 padding/margin | 4px | +| 选中底 | `--muted` | +| 外包 | `px-1.5 py-1` + 右边框 | +| 打开动画 | 18ms,opacity + 左移 12px | + +**交互:** + +- 单击文件 → 在统一标签栏开文件标签 +- 单击目录 → 展开/折叠 +- 多选 / Cmd / Shift 点击不自动打开 +- 树内拖拽移动;拖到终端标签或终端区域会把路径写进 PTY +- 内联重命名;新建草稿名硬编码 `"New File"` / `"New Folder"`(重名加数字) +- Git 状态混进路径列表(未跟踪/改动的文件即使目录未扫到也会出现) + +**条目右键:** Open · Open in Default App · Reveal in Finder · Refresh · New File · New Folder · Rename · Copy Relative Path · Copy Absolute Path · Delete(红) + +**空白/根右键:** New File · New Folder · Refresh · Reveal in Finder · Copy Relative / Absolute Path(根) + +加载中树自身处理;路径错误时半透明错误字叠在树上(不挡操作)。 + +### 8.2 Git — 紧凑变更面板 + +``` +竖向满高 +├── ChangesFileList(可滚) +│ └── sticky 头:全选 checkbox + "{n} changed file(s)" + 放大开 Diff 对话框 +│ └── 文件行:checkbox + 图标 + 名 + A/D/M/R 徽章 + discard +└── CommitComposer(底,上边框,padding 10×8) + ├── 小写标题 COMMIT + ├── Summary 单行(高 28px) + ├── Description 多行(最小 4.5rem) + ├── "{included} of {total} files included" + All / None + ├── Commit 按钮(需有摘要且至少勾一个文件) + └── 若 ahead>0:Push 按钮 + 上传图标 +``` + +空列表: “No changes detected”。双击文件打开大 Diff 并选中该文件。`Cmd/Ctrl+Enter` 提交。 + +### 8.3 Notes + +懒加载 Markdown 编辑器,占满侧栏。占位 “Write notes for this profile…”。自动保存。状态徽章:Saving / Saved / Failed。失败 Toast “Notes save failed”。 + +--- + +## 9. 统一标签栏 + 终端 + 文件查看器 + +### 9.1 标签栏 + +一条横滚、底边框的 line tabs。每个 trigger `max-width: 14rem`(`max-w-56`),左对齐。 + +**终端标签(左起):** + +1. 图标 14px:标题含 claude/codex/gemini/kimi/cline/openclaw/opencode/qoder 则用对应彩色 SVG,否则 TerminalWindow +2. 标题截断 +3. 运行/等待状态点;若只有 completed,点可点掉 +4. 关闭 X(16px 热区,12px 图标),`aria-label="Close {title}"` + +终端标签是文件树路径的 drop target。 + +**文件标签(接在终端后面):** + +1. 文件类型图标 14px +2. 文件名截断 +3. 未保存:8px muted 圆点 +4. 关闭(脏则先弹确认) + +**最右:New Terminal** + +- 无模板:一个 `+ New Terminal` tab-looking 控件 +- 有模板:主按钮 + 右侧 caret 分裂按钮,菜单分 **Project Templates** / **Global Templates** + - 项目模板可显示相对 cwd 副文案 + - 空提示去项目设置或应用设置加模板 + +### 9.2 空终端 CTA(无任何标签时) + +居中 Empty:终端窗图标 + “No terminals open” + “Open a terminal to start working in this project.” + 同上的 New Terminal(可分裂)。分裂按钮 `aria-label` 硬编码 `"Choose template"`。 + +### 9.3 终端本体 + +每个会话一个 xterm.js,绝对铺满标签下区域。非活动:`visibility:hidden` + `pointer-events:none`。恢复中居中 Spinner。 + +**必须遵守:** 不要因切标签卸载 xterm。WebKit 下字体测量必须用挂在 document 里的 canvas,否则半角字宽测错、右侧空一截。 + +终端内: + +- 搜索条:右上绝对定位(`top/right: 12px`),半透明底、边框、阴影。输入宽 224px 高 28px + `n/m` 或 “No results” + 上/下/关。Esc 关,Enter 下一个,Shift+Enter 上一个。高亮:普通匹配褐底 `#5f4b16`,当前蓝 `#1f6feb`。 +- 点链接:确认框,说明可 Ctrl+点击跳过;显示 URL;Default Browser / Open With。 +- 点文件路径:在工作区内则开文件标签;歧义则选择列表;工作区外提示不能打开。 +- 主题 10 套:GitHub Dark/Light、Dracula、Ayu Dark/Light、Solarized Dark/Light、One Dark/Light。暗/亮模式可各选一套,或同步。 + +全局(当前 Profile):`Cmd+T` 新终端,`Cmd+W` 关当前终端。 + +### 9.4 文件查看器 + +按扩展名三条路: + +**A. 文本(非 md)— Monaco** + +- 满高,无 minimap,无 word wrap,padding 上下 12px +- 字体 = 终端字体 + ligatures,字号 = 终端字号 +- 主题:终端主题名含 `light` 用 `light`,否则 `vs-dark` +- TS/JS 诊断关闭 +- `Cmd/Ctrl+S` 保存(窗口级 + 编辑器命令) +- 草稿 400ms 防抖;脏点在标签上 + +**B. Markdown / MDX — Milkdown** + +顶工具条(`px-2 py-1`,xs 图标按钮)+ 编辑区。工具:段落/H1–H3、粗斜代码删、链接、列表、引用、代码块、表、分割线、保存。`/` 斜杠菜单(最小宽 180、最高 280)。代码块深色 `#282c34`,语言选择器 240px,预览最高 420px。保存徽章与 Notes 相同。 + +**C. 二进制预览** + +顶条高 ≥36px,muted 底:文件名 + 右侧 “Preview” 或 “Office Preview”。 + +- 图:棋盘透明底,contain +- PDF / Office→PDF:白底 iframe +- 压缩包:`ArchivePreviewTree`,统计硬编码 `"{n} files / {m} folders"` +- 其它:“Preview unavailable”(硬编码) + +加载:高 128px 居中 Spinner。错误:居中 muted 消息。 + +关脏文件:对话框 “Close Unsaved File?” + “{file} has unsaved changes…” + Cancel / Discard Changes(红)。 + +--- + +## 10. 命令面板(Cmd/Ctrl+K) + +仅当前活动 Profile。 + +| 项 | 值 | +|----|-----| +| 遮罩 | 全屏 40% 黑,z 1400 | +| 面板 | 顶距 72px,水平居中,宽 `min(100%, 40rem)`,最高 `100vh-96px` | +| 圆角 | 8px | +| 边框 | `--border` | +| 背景 | `--popover` | + +结构: + +1. 底边框搜索行,`px-4 py-3`,无边框输入,16px,占位 “Search files by name…” +2. 列表最高 60vh:每项 图标16 + 文件名 14px + 父路径 12px muted;选中 `bg-muted` +3. 空:未输入 “Start typing…”;无结果 “No matching files found.”;错误显示消息 + +打开后 focus + 全选。选中 Enter → 开文件标签并关面板。自管过滤关(`shouldFilter=false`),结果来自后端搜索。 + +--- + +## 11. Git 大对话框 + +`Cmd+G` 或侧栏 Git 放大、或顶栏逻辑打开。 + +``` +DialogContent 宽 min(88rem, 100vw-2rem) 高 min(82dvh, 56rem) 无默认关闭钮 +├── Header(底边框,px-4 py-2) +│ ├── 左:Git 图标 + 分支名(缺省显示硬编码 "main") +│ ├── 中右:Unified / Split toggle +│ └── 关 X +└── 横向 flex-1 + ├── 左栏约 360px + │ └── Tabs: Changes | History + │ ├── Changes:文件列表 + CommitComposer(与侧栏同构,更宽) + │ └── History:提交列表 → 点进后变该提交的文件列表 + Back + └── 右:Diff pane(Pierre diffs + Shiki,主题跟终端主题映射) +``` + +文件徽章:A / D / M / R。大 diff(超过阈值的行数)默认折叠,说明 “Large diff hidden by default”,按钮 “Load diff anyway”。重命名显示 Previous/Current path。图片 Before/After。二进制不可预览走 “Preview unavailable”。 + +Review Queue:浮动按钮,收集 diff 评论,可复制/复制并清空。 + +History 空:“No commits found”。无文件变更:“No file changes”。未选文件:“Select a file to view changes”。 + +--- + +## 12. 设置窗口(六个标签) + +顶:横向 TabsList,`margin: 20px 20px 0`,溢出横滚。每项 图标 + 字。默认 General(URL 无 `tab`);其它写入 `?tab=`。 + +内容区 `padding: 20px`,可滚。 + +### 12.1 General + +单列 `max-w-md`,项间距 24px。 + +1. **Language** — native select:English / 中文 +2. **Theme** — System / Light / Dark(应用 chrome,不是终端) +3. **Border Radius** — 五档 +4. **Default Worktree Directory** — 输入,占位 `Default: ~/.2code/workspace`,可清空。说明:项目没设 `worktree_dir` 时新 Profile 用它;相对路径相对项目根 +5. **Debug Mode** — 横排:标题+说明 / Switch。说明 “Show backend log events in a floating panel.” +6. **Performance Profiling** — 同上,写前后端 trace +7. **Show Project Avatars** — 控制侧栏 16px 头像 + +### 12.2 Terminal + +左列(`max-w-md`)+ 右列实时 `TerminalPreview`,间距 32px。 + +左:终端主题(可预览 hover)、Default Shell(系统列表 + Custom 路径)、Font(系统字体,可 “Show all fonts”)、Font Size stepper。Shell/Font 加载失败:70px 高骨架或 InlineError。 + +### 12.3 Terminal Templates + +`max-w-2xl`。全局模板列表:名、shell、cwd、命令(一行一条)。增删改。空:“No templates yet.” + +### 12.4 Notification + +总开关 “Enable Notifications” + 说明。Sound:系统音列表或 None;不可用时 “No system sounds”。Agent 等待会弹系统通知并按配置播放声音。 + +### 12.5 Top Bar + +预览当前控件 + 可拖 “Available Controls”。提示 “Drag controls to reorder or move between areas.” Reset to Defaults。Editor / Terminal 应用下拉。检测中 “Detecting installed apps...”。空槽 “No controls in the top bar…” + +控件显示名:GitHub Desktop、Editor、Terminal、Pull Request。编辑器/终端选项见 `messages/en.json` 的 `topbar*`。 + +### 12.6 About + +应用图标、描述 “The Vibe Coding Workstation — …”、可点复制的 Version 徽章、维护者 AkaraChen(头像+主维护说明)、Repository / Releases / Website 外链。更新卡片:Check for Updates、Accept Beta Updates 开关、有更新则 Install {version}。状态文案见 i18n `update*`。 + +--- + +## 13. 全部对话框目录 + +凡未写尺寸的,都用 §3.5 标准小对话框。 + +### 13.1 Create Project + +触发:侧栏 `+`。 + +- 标题:FolderPlus + “Create Project” +- **未选文件夹:** 虚线大按钮,Folder 24px + “Choose Folder”,hover muted +- **已选:** 上 “Folder” + xs “Choose Folder” 铅笔;下 code 块显示路径 +- Project Name 输入,占位 “Optional. Leave empty to use folder name” +- 说明随状态变三条 hint(先选文件夹 / 只用文件夹名 / 自定义名) +- Footer:Cancel / Create(无文件夹或进行中禁用;进行中出 Spinner) +- 选文件夹用系统目录对话框;若名为空则填 basename +- 成功关窗并导航到新项目第一个 Profile +- Enter 在名称框提交 + +### 13.2 Delete Project + +触发:项目右键。 + +- Trash + “Delete Project” +- “Are you sure you want to delete this project? This action cannot be undone.” +- Cancel / Delete(红,进行中 Spinner) +- 若删的是当前项目:跳到列表里相邻项目的默认 Profile,否则回 `/` + +### 13.3 Rename Project + +触发:项目右键。打开时 focus 输入。 + +- Pencil + “Rename” +- “New Name” +- Cancel / Rename(空或未改禁用) + +### 13.4 Project Settings + +触发:项目右键或顶栏齿轮。 + +- Gear + “Project Settings” +- 内 Tabs:**Scripts**(Code 图标)/ **Templates**(终端窗图标) +- Scripts:Worktree Directory + Init / Setup / Teardown 三个等宽 textarea(4 行,占位 “One command per line…”) +- Templates:项目级终端模板编辑器(与全局同构) +- Cancel / Save(进行中 Spinner) +- 加载失败:DialogBodyError + +### 13.5 Create Profile + +触发:项目行 `+` 或子列表 “New Profile”。 + +- GitBranch + “New Profile” +- Branch Name,占位说明可留空自动生成 `pr/tokyo-a1b2c3d4` 这类名 +- Cancel / Create → 导航到新 Profile + +### 13.6 Delete Profile + +触发:非默认 Profile 右键。 + +- Trash + “Delete Profile” +- 确认 worktree 会删 +- 检查中:Spinner + “Checking this profile's Git status…” +- 有风险:Alert “Check Git changes before deleting” + 未提交/未推送/合计 diff 数字 +- 检查失败:另一 Alert,请手动确认 +- 主按钮在有风险时改成 “Delete Anyway” +- 删当前 Profile 则回默认/其它 Profile 或 `/` + +### 13.7 Close Unsaved File + +- Warning + “Close Unsaved File?” +- “{file} has unsaved changes. Closing it will discard those changes.” +- Cancel / Discard Changes(红) + +### 13.8 Switch Branch + +- GitBranch + “Switch branch” +- 搜索框 “Search branches…” +- 列表行:图标 + 名 + 徽章 current/trunk/used + ↑ahead ↓behind +- current / used 禁用;used 琥珀徽章(已被其它 Profile 占用) +- 当前行 muted 底;其它 hover +- 空:“No branches found” +- 成功 Toast “Switched to {branch}”;失败 “Checkout failed” + +### 13.9 终端相关 + +- Open Link 确认 +- Choose File(歧义路径) +- 模板草稿编辑(名、shell、cwd、命令) + +### 13.10 Git Review Queue + +从 Diff 对话框打开。标题 “Review Queue”。复制 / 复制并清空。Toast “Review comments copied”。 + +### 13.11 Debug Log + +设置里打开 Debug Mode 后,主窗右下 **扳手圆钮**(`fixed right-16 bottom-4 z-50`)。点开日志对话框:搜索 “Search logs...”、Clear、自动滚、空 “No log entries yet”。`Cmd/Ctrl+Shift+D` 切换面板。 + +--- + +## 14. 叠加层与全局反馈 + +| 层 | z / 位置 | 内容 | +|----|----------|------| +| 命令面板遮罩 | 1400 | 40% 黑 | +| 命令面板 | 1401 | 见 §10 | +| 对话框 | 50 | 见 §3.5 | +| 菜单/tooltip | popover | 圆角 8、阴影、细环 | +| Toast | Sonner 默认 | 见 §3.6 | +| 调试 FAB | 50,右下偏左 | 仅 debug 开 | +| 新手引导 | driver.js | 见 §5 | +| 启动更新 | Toast 12s | 见 §2.4 | + +侧栏 Home 的 onboarding 锚在 `#add-project-button`,不在 Home DOM 里。 + +--- + +## 15. 键盘一览 + +| 快捷键 | 范围 | 动作 | +|--------|------|------| +| `Cmd/Ctrl+,` | 全局 | 开设置窗 | +| `Cmd/Ctrl+Shift+D` | 全局 | 开/关调试面板 | +| `Cmd/Ctrl+K` | 活动 Profile | 命令面板 | +| `Cmd/Ctrl+T` | 活动 Profile | 新终端 | +| `Cmd/Ctrl+W` | 活动 Profile | 关当前终端 | +| `Cmd/Ctrl+E` | 活动 Profile | 开关次级侧栏 | +| `Cmd/Ctrl+G` | 活动 Profile | 开 Git Diff | +| `Cmd/Ctrl+S` | 文件查看器可见 | 保存 | +| `Cmd/Ctrl+Enter` | Commit 摘要/正文 | 提交 | +| `Enter` / `Shift+Enter` | 终端搜索 | 下/上一个匹配 | +| `Esc` | 搜索/多数对话框 | 关闭 | +| `↑` `↓` | 应用侧栏 | 项目/Profile 间移动焦点 | +| `←` `→` / Home / End | resize 热区 | 调宽度 | +| `Enter` / `Space` | 分组/部分动作 | 切换 | + +macOS 用 Meta,其它用 Ctrl。TerminalLayer 的 T/W 当前实现检查 `metaKey`(macOS 路径)。 + +--- + +## 16. 平台差异(必须实现) + +| | macOS | Windows | Linux | +|--|-------|---------|-------| +| 标题栏 | Overlay + 红绿灯 (16,24) | Overlay + 自定义 min/max/close | Overlay,无自定义按钮 | +| 侧栏 header padding-top | 32px | 8px | 8px | +| 侧栏折叠后顶栏 padding-left | 84px | 正常 | 正常 | +| 顶栏 padding-right | 20px | 118px | 20px | +| Reveal 文案 | “Reveal in Finder” | 同一 i18n key,实现应走系统文件管理器 | 同左 | +| 字体列表 | core-text | fontdb | fontdb | +| 系统音 | /System/Library/Sounds + afplay | C:\Windows\Media + PowerShell | XDG + 桌面播放器 | +| 顶栏外开 App | 探测本机已装的编辑器/终端/GitHub Desktop | 同 | 同 | + +--- + +## 17. 持久化(影响 UI 还原) + +| Store / key | 影响的 UI | +|-------------|-----------| +| `app-sidebar-width` | 侧栏宽、折叠、分组折叠 | +| `file-tree-panel` | 次级侧栏宽 | +| themeStore | 亮/暗/系统 | +| terminalSettingsStore | 字体、字号、主题、shell | +| notificationStore | 通知开关、声音 | +| sidebarSettingsStore | 是否显示项目头像 | +| topbar store | 控件顺序、编辑器/终端选择 | +| updater settings | 是否收 beta | +| debugStore | 调试是否启用(FAB 显隐) | +| locale | en / zh | + +设置窗与主窗必须共用这些值(跨窗 sync)。 + +--- + +## 18. 硬编码、未走 i18n 的字(重写时建议补) + +| 位置 | 字符串 | +|------|--------| +| 侧栏品牌 | `2Code` | +| 应用侧栏 resize aria | `Resize sidebar`(次级侧栏已 i18n) | +| 模板分裂按钮 | `Choose template` | +| Git Diff 关 | `Close` | +| Git 无分支时 | `main` | +| 文件树草稿名 | `New File` / `New Folder` | +| 预览失败 | `Preview unavailable` | +| 预览条 | `Preview` / `Office Preview` | +| 压缩包统计 | `{n} files / {m} folders` | +| 完成点 | `Dismiss completion notification` | +| 关标签 | `Close {title}` | +| Windows 按钮 | Minimize / Restore / Maximize / Close | +| 设置窗 title | `Settings` | +| 链接占位 | `https://` | +| About 作者 | `AkaraChen`、仓库 URL | + +中文包在 `messages/zh.json`,key 与 `en.json` 对齐。重写时不要把英文写死进布局,除上表。 + +--- + +## 19. 完整组件树(实现对照) + +``` +[主窗口] +App +├── StartupUpdateCheck +├── AppSidebar +│ ├── Header: "2Code" + collapse +│ ├── Home? | Pinned[] | Projects[ Group | Project ] +│ │ └── Project +│ │ ├── Avatar + name + (+|caret|agent-dot) +│ │ ├── context: group / settings / rename / delete +│ │ └── sub: Default, Profile…, New Profile +│ ├── Footer: Settings +│ ├── resize +│ └── CreateProjectDialog +├── main +│ ├── HomePage | (empty ProjectDetailPage) +│ └── TerminalLayer +│ └── per profile (display none unless active) +│ └── ProfileLayout +│ ├── CommandPalette +│ ├── ProjectTopBar +│ │ ├── expand sidebar? +│ │ ├── Files | Git+stats | Notes +│ │ ├── project name + branch → SwitchBranchDialog +│ │ ├── topbar controls + project settings +│ │ └── GitDiffDialog +│ ├── ProfileSidebar +│ │ ├── FileTreePanel +│ │ ├── SidebarGitPanel → CommitComposer + ChangesFileList +│ │ └── ProfileNotesEditor → MarkdownEditor +│ └── TerminalTabs +│ ├── terminal tabs + file tabs + New Terminal menu +│ ├── FileViewerPane (Monaco | Markdown | preview) +│ ├── Terminal… (xterm, search, link dialogs) +│ └── UnsavedFileCloseDialog +├── DebugFloat → DebugLogDialog +└── WindowControls? + +[设置窗口] +SettingsWindow → SettingsPage +├── General | Terminal+Preview | Templates | Notification | Top Bar | About +``` + +--- + +## 20. 重写验收清单 + +做完新 UI 后,对照下列是否 **看起来和用起来** 一样(不必同技术栈): + +1. 冷启动无项目:左栏只有 Home + Settings,右栏 Home 空态,引导指向 `+`。 +2. 建项目:虚线选文件夹 → 命名 → 进入默认 Profile 空终端 CTA。 +3. 开终端:标签出现,xterm 铺满;再开一个;切换不丢滚动/进程。 +4. 切到另一 Profile 再切回:终端还在。 +5. 文件树开文件:标签出现;md 走工具条编辑器;图走棋盘预览;脏点;关时确认。 +6. Cmd+K 搜文件打开。 +7. 次级侧栏 Files/Git/Notes 切换,Git 显示 +/-,Notes 能写能存。 +8. Cmd+G 大 Diff:Changes 提交/推送,History 看提交,Unified/Split。 +9. 顶栏点项目名开 Finder;点分支切换;齿轮开项目脚本/模板。 +10. 侧栏钉选、分组、拖拽排序、折叠整栏、拖宽。 +11. Cmd+, 独立设置窗;改主题/字体/圆角/头像,主窗立刻变。 +12. Debug 开后右下扳手出日志。 +13. 有更新时 Toast 12s,动作进 About。 +14. Windows 右上三按钮可用;macOS 红绿灯不被标题挡住。 +15. 中英切换,布局不碎。 + +--- + +## 21. 附录索引 + +需要像素级 className、token 表、完整 i18n key 列表时打开: + +1. [sidebar-ui-inventory.md](./sidebar-ui-inventory.md) +2. [ui-inventory-home-project.md](./ui-inventory-home-project.md) +3. [ui-inventory-settings-terminal-git-debug-updater.md](./ui-inventory-settings-terminal-git-debug-updater.md) +4. 文案源:[`messages/en.json`](../messages/en.json) / [`messages/zh.json`](../messages/zh.json) +5. 颜色与全局 CSS:[`src/app.css`](../src/app.css) +6. 窗口配置:[`src-tauri/tauri.conf.json`](../src-tauri/tauri.conf.json) From 45aea307d6a6f4a1c7170bda58859c6258aced6d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:22:57 +0000 Subject: [PATCH 02/50] Add GPUI native rewrite of the 2code desktop shell. Introduce src-gpui, a GPUI + gpui-component app that talks to the existing Rust domain crates directly. It implements the inventory surfaces: two windows, app sidebar, home, profile workspace, persistent PTY terminals, file viewer, git panel/diff, command palette, dialogs, settings, debug, and en/zh i18n. Tauri/React remains in-tree as the legacy shell while this becomes the new primary binary. Co-authored-by: AkaraChen --- docs/README.md | 6 +- justfile | 7 + src-gpui/.gitignore | 1 + src-gpui/Cargo.lock | 8479 +++++++++++++++++++ src-gpui/Cargo.toml | 33 + src-gpui/README.md | 28 + src-gpui/src/app.rs | 1336 +++ src-gpui/src/backend.rs | 466 + src-gpui/src/i18n.rs | 67 + src-gpui/src/main.rs | 61 + src-gpui/src/prefs.rs | 185 + src-gpui/src/state.rs | 343 + src-gpui/src/ui/debug.rs | 106 + src-gpui/src/ui/dialogs.rs | 700 ++ src-gpui/src/ui/file_tree.rs | 157 + src-gpui/src/ui/file_viewer.rs | 167 + src-gpui/src/ui/git.rs | 633 ++ src-gpui/src/ui/home.rs | 56 + src-gpui/src/ui/mod.rs | 19 + src-gpui/src/ui/notes.rs | 57 + src-gpui/src/ui/palette.rs | 131 + src-gpui/src/ui/settings.rs | 745 ++ src-gpui/src/ui/shell.rs | 116 + src-gpui/src/ui/sidebar.rs | 609 ++ src-gpui/src/ui/terminal.rs | 134 + src-gpui/src/ui/workspace.rs | 633 ++ src-tauri/crates/model/src/profile.rs | 2 +- src-tauri/crates/model/src/project.rs | 4 +- src-tauri/crates/model/src/project_group.rs | 2 +- 29 files changed, 15278 insertions(+), 5 deletions(-) create mode 100644 src-gpui/.gitignore create mode 100644 src-gpui/Cargo.lock create mode 100644 src-gpui/Cargo.toml create mode 100644 src-gpui/README.md create mode 100644 src-gpui/src/app.rs create mode 100644 src-gpui/src/backend.rs create mode 100644 src-gpui/src/i18n.rs create mode 100644 src-gpui/src/main.rs create mode 100644 src-gpui/src/prefs.rs create mode 100644 src-gpui/src/state.rs create mode 100644 src-gpui/src/ui/debug.rs create mode 100644 src-gpui/src/ui/dialogs.rs create mode 100644 src-gpui/src/ui/file_tree.rs create mode 100644 src-gpui/src/ui/file_viewer.rs create mode 100644 src-gpui/src/ui/git.rs create mode 100644 src-gpui/src/ui/home.rs create mode 100644 src-gpui/src/ui/mod.rs create mode 100644 src-gpui/src/ui/notes.rs create mode 100644 src-gpui/src/ui/palette.rs create mode 100644 src-gpui/src/ui/settings.rs create mode 100644 src-gpui/src/ui/shell.rs create mode 100644 src-gpui/src/ui/sidebar.rs create mode 100644 src-gpui/src/ui/terminal.rs create mode 100644 src-gpui/src/ui/workspace.rs diff --git a/docs/README.md b/docs/README.md index 5e703b6c..d81420a6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,7 +10,8 @@ | Layer | Technology | | -------------- | ------------------------------------------ | -| Frontend | React 19, TypeScript, Vite 8, shadcn/ui | +| Native UI | GPUI + gpui-component (`src-gpui/`) | +| Frontend | React 19, TypeScript, Vite 8, shadcn/ui (legacy Tauri shell) | | State (client) | Zustand 5 + immer | | State (server) | TanStack Query 5 | | Routing | react-router v7 | @@ -42,6 +43,8 @@ │ ├── generated/ # Auto-generated Tauri IPC bindings (gitignored) │ └── paraglide/ # Generated i18n code (gitignored) │ +├── src-gpui/ # GPUI native rewrite (primary desktop shell) +│ ├── src-tauri/ # Backend (Rust) │ ├── src/ │ │ ├── lib.rs # App setup: plugins, state, commands, lifecycle @@ -74,3 +77,4 @@ | [Sidebar UI Inventory](sidebar-ui-inventory.md) | Pixel-level appendix: app sidebar + profile sidebar | | [Home / Project UI Inventory](ui-inventory-home-project.md) | Pixel-level appendix: home, file tree, viewer, command palette, project dialogs | | [Settings / Terminal / Git UI Inventory](ui-inventory-settings-terminal-git-debug-updater.md) | Pixel-level appendix: settings window, terminal, git, debug, updater | +| [GPUI rewrite](../src-gpui/README.md) | Native GPUI desktop shell that implements the UI inventory | diff --git a/justfile b/justfile index 6fbdc9bf..e7afb777 100644 --- a/justfile +++ b/justfile @@ -4,6 +4,13 @@ start: fmt: fama "./src/**/*.{ts,tsx}" cd src-tauri && cargo fmt + cd src-gpui && cargo fmt + +gpui: + cd src-gpui && cargo run + +gpui-check: + cd src-gpui && cargo check test-frontend: bun run test diff --git a/src-gpui/.gitignore b/src-gpui/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/src-gpui/.gitignore @@ -0,0 +1 @@ +/target diff --git a/src-gpui/Cargo.lock b/src-gpui/Cargo.lock new file mode 100644 index 00000000..f70c9867 --- /dev/null +++ b/src-gpui/Cargo.lock @@ -0,0 +1,8479 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6" +dependencies = [ + "object", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ash-window" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" +dependencies = [ + "ash", + "raw-window-handle", + "raw-window-metal", +] + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.13", + "zbus", +] + +[[package]] +name = "ashpd" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a3c86f3fd70c0ffa500ed189abfa90b5a52398a45d5dc372fcc38ebeb7a645" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.5", + "serde", + "serde_repr", + "url", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand 2.5.0", + "futures-lite 2.6.1", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite 2.6.1", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite 2.6.1", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.2", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.2", + "futures-lite 2.6.1", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite 2.6.1", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "async_zip" +version = "0.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" +dependencies = [ + "async-compression", + "crc32fast", + "futures-lite 2.6.1", + "pin-project", + "thiserror 1.0.69", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.20", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "blade-graphics" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e71cfb73b98eb9f58ee84048aa1bdf4e7497fd20c141b57523499fa066b48fed" +dependencies = [ + "ash", + "ash-window", + "bitflags 2.13.1", + "bytemuck", + "codespan-reporting", + "glow", + "gpu-alloc", + "gpu-alloc-ash", + "hidden-trait", + "js-sys", + "khronos-egl", + "libloading", + "log", + "mint", + "naga", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "objc2-ui-kit", + "once_cell", + "raw-window-handle", + "slab", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "blade-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27142319e2f4c264581067eaccb9f80acccdde60d8b4bf57cc50cd3152f109ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "blade-util" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a6be3a82c001ba7a17b6f8e413ede5d1004e6047213f8efaf0ffc15b5c4904c" +dependencies = [ + "blade-graphics", + "bytemuck", + "log", + "profiling", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite 2.6.1", + "piper", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cbindgen" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" +dependencies = [ + "heck 0.4.1", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation 0.1.2", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +dependencies = [ + "bitflags 2.13.1", + "block", + "cocoa-foundation 0.2.0", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "libc", + "objc", +] + +[[package]] +name = "code-gpui" +version = "3.0.1" +dependencies = [ + "anyhow", + "diesel", + "dirs 6.0.0", + "gpui", + "gpui-component", + "infra", + "libsqlite3-sys", + "model", + "open", + "repo", + "rfd", + "serde", + "serde_json", + "service", + "tracing", + "tracing-subscriber", + "uuid", + "vt100", +] + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "command-fds" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b60b5124979fccd9addd89d8b97a1d6eebb4950694520c75ddd722535ea443f" +dependencies = [ + "nix 0.31.3", + "thiserror 2.0.20", +] + +[[package]] +name = "compression-codecs" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" +dependencies = [ + "compression-core", + "deflate64", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.0", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-helmer-fork" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "core-graphics2" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e4583956b9806b69f73fcb23aee05eb3620efc282972f08f6a6db7504f8334d" +dependencies = [ + "bitflags 2.13.1", + "block", + "cfg-if", + "core-foundation 0.10.0", + "libc", +] + +[[package]] +name = "core-text" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" +dependencies = [ + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-video" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45e71d5be22206bed53c3c3cb99315fc4c3d31b8963808c6bc4538168c4f8ef" +dependencies = [ + "block", + "core-foundation 0.10.0", + "core-graphics2", + "io-surface", + "libc", + "metal", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da46a9d5a8905cc538a4a5bceb6a4510de7a51049c5588c0114efce102bcbbe8" +dependencies = [ + "bitflags 2.13.1", + "fontdb 0.16.2", + "log", + "rangemap", + "rustc-hash 1.1.0", + "rustybuzz 0.14.1", + "self_cell", + "smol_str", + "swash", + "sys-locale", + "ttf-parser 0.21.1", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deflate64" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "diesel" +version = "2.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "715377c6e464cb44bb89bd8487584240516c8d5052bc645d6babc50bb8be46c3" +dependencies = [ + "diesel_derives", + "downcast-rs 2.0.2", + "libsqlite3-sys", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling", + "either", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dtor" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.5+spec-1.1.0", + "vswhom", + "winreg 0.55.0", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.2", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser 0.25.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand 2.5.0", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +dependencies = [ + "bitflags 2.13.1", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-ash" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643756f08ef6def813c776199e4766596395a4c6530373c9a4374a64de2f53a1" +dependencies = [ + "ash", + "gpu-alloc-types", + "tinyvec", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "gpui" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "979b45cfa6ec723b6f42330915a1b3769b930d02b2d505f9697f8ca602bee707" +dependencies = [ + "anyhow", + "as-raw-xcb-connection", + "ashpd 0.11.1", + "async-task", + "bindgen", + "blade-graphics", + "blade-macros", + "blade-util", + "block", + "bytemuck", + "calloop", + "calloop-wayland-source", + "cbindgen", + "cocoa 0.26.0", + "cocoa-foundation 0.2.0", + "core-foundation 0.10.0", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "core-video", + "cosmic-text", + "ctor", + "derive_more", + "embed-resource", + "etagere", + "filedescriptor", + "flume", + "foreign-types", + "futures", + "gpui-macros", + "gpui_collections", + "gpui_http_client", + "gpui_media", + "gpui_refineable", + "gpui_semantic_version", + "gpui_sum_tree", + "gpui_util", + "gpui_util_macros", + "image", + "inventory", + "itertools 0.14.0", + "libc", + "log", + "lyon", + "metal", + "naga", + "num_cpus", + "objc", + "oo7", + "open", + "parking", + "parking_lot", + "pathfinder_geometry", + "pin-project", + "postage", + "profiling", + "rand 0.9.5", + "raw-window-handle", + "resvg", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "smol", + "stacksafe", + "strum 0.27.2", + "taffy", + "thiserror 2.0.20", + "usvg", + "uuid", + "waker-fn", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-protocols 0.31.2", + "wayland-protocols-plasma", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-numerics", + "windows-registry 0.5.3", + "x11-clipboard", + "x11rb", + "xkbcommon", + "zed-font-kit", + "zed-scap", + "zed-xim", +] + +[[package]] +name = "gpui-component" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d021d46b4088d3d93a57ccdf443da85695a77272108caca2f6fe5369f584966a" +dependencies = [ + "aho-corasick", + "anyhow", + "chrono", + "core-text", + "enum-iterator", + "gpui", + "gpui-component-macros", + "gpui-macros", + "html5ever", + "itertools 0.13.0", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "notify 7.0.0", + "num-traits", + "once_cell", + "paste", + "regex", + "ropey", + "rust-i18n", + "schemars", + "serde", + "serde_json", + "serde_repr", + "smallvec", + "smol", + "tracing", + "tree-sitter", + "tree-sitter-json", + "unicode-segmentation", + "uuid", + "zed-sum-tree", +] + +[[package]] +name = "gpui-component-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86fbc2d84bf91717b171320e6adc600d91ccb3ed259448f3b006787633c1c615" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcb02dd63a2859714ac7b6b476937617c3c744157af1b49f7c904023a79039be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui_collections" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae39dc6d3d201be97e4bc08d96dbef2bc5b5c3d5734e05786e8cc3043342351c" +dependencies = [ + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "gpui_derive_refineable" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "644de174341a87b3478bd65b66bca38af868bcf2b2e865700523734f83cfc664" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui_http_client" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23822b0a6d2c5e6a42507980a0ab3848610ea908942c8ef98187f646f690335e" +dependencies = [ + "anyhow", + "async-compression", + "async-fs", + "bytes", + "derive_more", + "futures", + "gpui_util", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "url", + "zed-async-tar", + "zed-reqwest", +] + +[[package]] +name = "gpui_media" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cb8912ae17371725132d2b7eec6797a255accc95d58ee5c1134b529810f14b" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.0", + "core-video", + "ctor", + "foreign-types", + "metal", + "objc", +] + +[[package]] +name = "gpui_perf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40a0961dcf598955130e867f4b731150a20546427b41b1a63767c1037a86d77" +dependencies = [ + "gpui_collections", + "serde", + "serde_json", +] + +[[package]] +name = "gpui_refineable" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "258cb099254e9468181aee5614410fba61db4ae115fc1d51b4a0b985f60d6641" +dependencies = [ + "gpui_derive_refineable", +] + +[[package]] +name = "gpui_semantic_version" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201e45eff7b695528fb3af6560a534943fbc2db5323d755b9d198bd743948e35" +dependencies = [ + "anyhow", + "serde", +] + +[[package]] +name = "gpui_sum_tree" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f3bedd573fafafa13d1200b356c588cf094fb2786e3684bb3f5ea59b549fa9" +dependencies = [ + "arrayvec", + "log", + "rayon", +] + +[[package]] +name = "gpui_util" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68faea25903ae524de9af83990b9aa51bcbc8dd085929ac0aea7fd41905e05c3" +dependencies = [ + "anyhow", + "async-fs", + "async_zip", + "command-fds", + "dirs 4.0.0", + "dunce", + "futures", + "futures-lite 1.13.0", + "globset", + "gpui_collections", + "itertools 0.14.0", + "libc", + "log", + "nix 0.29.0", + "regex", + "rust-embed", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "shlex 1.3.0", + "smol", + "take-until", + "tempfile", + "tendril", + "unicase", + "walkdir", + "which", +] + +[[package]] +name = "gpui_util_macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c28f65ef47fb97e21e82fd4dd75ccc2506eda010c846dc8054015ea234f1a22" +dependencies = [ + "gpui_perf", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "grid" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hidden-trait" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ed9e850438ac849bec07e7d09fbe9309cbd396a5988c30b010580ce08860df" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infra" +version = "0.1.0" +dependencies = [ + "diesel", + "diesel_migrations", + "flate2", + "ignore", + "model", + "notify 8.2.0", + "pinyin", + "portable-pty", + "serde", + "serde_json", + "slug", + "tar", + "tempfile", + "tracing", + "tracing-subscriber", + "zip", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-surface" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" +dependencies = [ + "cgl", + "core-foundation 0.10.0", + "core-foundation-sys", + "leaky-cow", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" + +[[package]] +name = "leaky-cow" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" +dependencies = [ + "leak", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dabea159dc6eea9171a541b458134b70ca95e3162d068132b2b3b9bc01aad06e" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.1.3", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mint" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53debba6bda7a793e5f99b8dacf19e626084f525f7829104ba9898f367d85ff" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "model" +version = "0.1.0" +dependencies = [ + "diesel", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "25.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg_aliases 0.2.2", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "strum 0.26.3", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.2.2", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.1", + "filetime", + "fsevent-sys", + "inotify 0.10.2", + "kqueue", + "libc", + "log", + "mio", + "notify-types 1.0.1", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.1", + "fsevent-sys", + "inotify 0.11.5", + "kqueue", + "libc", + "log", + "mio", + "notify-types 2.1.0", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oo7" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" +dependencies = [ + "aes", + "ashpd 0.12.3", + "async-fs", + "async-io", + "async-lock", + "blocking", + "cbc", + "cipher", + "digest 0.10.7", + "endi", + "futures-lite 2.6.1", + "futures-util", + "getrandom 0.3.4", + "hkdf", + "hmac", + "md-5", + "num", + "num-bigint-dig", + "pbkdf2", + "rand 0.9.5", + "serde", + "sha2 0.10.9", + "subtle", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + +[[package]] +name = "open" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.8", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pinyin" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e225f595052d9c46045755be4b8d7950b6d9f3c33e0c0b74ba58f11bbfa8c64b" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand 2.5.0", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs 1.2.1", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic", + "crossbeam-queue", + "futures", + "log", + "parking_lot", + "pin-project", + "pollster 0.2.5", + "static_assertions", + "thiserror 1.0.69", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "psm" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases 0.2.2", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases 0.2.2", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.20", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" +dependencies = [ + "cocoa 0.25.0", + "core-graphics 0.23.2", + "objc", + "raw-window-handle", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "repo" +version = "0.1.0" +dependencies = [ + "diesel", + "model", + "tracing", +] + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd 0.11.1", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster 0.4.0", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ropey" +version = "2.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" +dependencies = [ + "str_indices", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "globset", + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rust-i18n" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332" +dependencies = [ + "globwalk", + "once_cell", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965" +dependencies = [ + "glob", + "once_cell", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "serde_yaml", + "syn 2.0.119", +] + +[[package]] +name = "rust-i18n-support" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "lazy_static", + "normpath", + "once_cell", + "proc-macro2", + "regex", + "serde", + "serde_json", + "serde_yaml", + "siphasher", + "toml 0.8.23", + "triomphe", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "libm", + "smallvec", + "ttf-parser 0.21.1", + "unicode-bidi-mirroring 0.2.0", + "unicode-ccc 0.2.0", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser 0.25.1", + "unicode-bidi-mirroring 0.4.0", + "unicode-ccc 0.4.0", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "screencapturekit" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" +dependencies = [ + "screencapturekit-sys", +] + +[[package]] +name = "screencapturekit-sys" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" +dependencies = [ + "block", + "dispatch", + "objc", + "objc-foundation", + "objc_id", + "once_cell", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.0", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_lenient" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "service" +version = "0.1.0" +dependencies = [ + "diesel", + "dirs 6.0.0", + "infra", + "model", + "notify 8.2.0", + "repo", + "tracing", + "uuid", + "vt100", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite 2.6.1", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "stacksafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" +dependencies = [ + "stacker", + "stacksafe-macro", +] + +[[package]] +name = "stacksafe-macro" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" +dependencies = [ + "proc-macro-error2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sval" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b81b254da21fe1fcc4e3a74fe39b46e25e3a863078f8b71c954d47f84889dbc6" + +[[package]] +name = "sval_buffer" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50be352d2822ffafb59e3e2ddac9d5ee60f2eeadbb7b5a2a951b9f3651e87a6f" +dependencies = [ + "sval", + "sval_ref", + "zerocopy", +] + +[[package]] +name = "sval_dynamic" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048ca293b998d9a45659159f94a64063791e74cdc670164943dbb434405573d" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b5888e40f80568733217f27b7317b845f463400ced36c424b1a804730e53b2" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17664d6bb6b74947afaab9d7c991caa9bf5638d4dee16fcbef637f440796049" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c059969ca5ca163ea7fef6c9661758973d17691aba92abdcf5c428f4ec122c" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d6b29ff568c85c87561807f51d2adfff4b6016c6363133f7cd1652a12548f3" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f33ec9edc42b12764d5c90ca0a1d84189c6bde81ed27507f1e661c6e4e05853" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "taffy" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13e5d13f79d558b5d353a98072ca8ca0e99da429467804de959aa8c83c9a004" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "take-until" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" + +[[package]] +name = "tao-core-video-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "objc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr 1.6.1", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand 2.5.0", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-json" +version = "0.24.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d727acca406c0020cffc6cf35516764f36c8e3dc4408e5ebe2cb35a947ec471" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" + +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb 0.23.0", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz 0.20.1", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0941feceafbe7a8f59ea1096d45b97002884a41306315ad797b3684b63a81d8c" +dependencies = [ + "erased-serde", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839752af8179287d27eb2b94164641b1ede9e60ab7424163388dc21ebd0508cd" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs 1.2.1", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.31.2", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-capture" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" +dependencies = [ + "parking_lot", + "rayon", + "thiserror 2.0.20", + "windows 0.61.3", + "windows-future", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "workspace-hack" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-clipboard" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" +dependencies = [ + "libc", + "x11rb", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "rustix 1.1.4", + "x11rb-protocol", + "xcursor", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc" +dependencies = [ + "libc", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xcb" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" +dependencies = [ + "bitflags 2.13.1", + "libc", + "quick-xml", + "x11", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xim-ctext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac61a7062c40f3c37b6e82eeeef835d5cc7824b632a72784a89b3963c33284c" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "xim-parser" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dcee45f89572d5a65180af3a84e7ddb24f5ea690a6d3aa9de231281544dd7b7" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "as-raw-xcb-connection", + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever", +] + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yazi" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener 5.4.2", + "futures-core", + "futures-lite 2.6.1", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zed-async-tar" +version = "0.5.0-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cf4b5f655e29700e473cb1acd914ab112b37b62f96f7e642d5fc6a0c02eb881" +dependencies = [ + "async-std", + "filetime", + "libc", + "pin-project", + "redox_syscall 0.2.16", + "xattr 0.2.3", +] + +[[package]] +name = "zed-font-kit" +version = "0.14.1-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3898e450f36f852edda72e3f985c34426042c4951790b23b107f93394f9bff5" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "core-foundation 0.10.0", + "core-graphics 0.24.0", + "core-text", + "dirs 5.0.1", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "zed-reqwest" +version = "0.12.15-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8" +dependencies = [ + "anyhow", + "cocoa 0.25.0", + "core-graphics-helmer-fork", + "log", + "objc", + "rand 0.8.8", + "screencapturekit", + "screencapturekit-sys", + "sysinfo", + "tao-core-video-sys", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + +[[package]] +name = "zed-sum-tree" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" +dependencies = [ + "arrayvec", + "log", + "rayon", + "workspace-hack", +] + +[[package]] +name = "zed-xim" +version = "0.4.0-zed" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0b46ed118eba34d9ba53d94ddc0b665e0e06a2cf874cfa2dd5dec278148642" +dependencies = [ + "ahash", + "hashbrown 0.14.5", + "log", + "x11rb", + "xim-ctext", + "xim-parser", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "url", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow 1.0.4", +] diff --git a/src-gpui/Cargo.toml b/src-gpui/Cargo.toml new file mode 100644 index 00000000..9b32eec3 --- /dev/null +++ b/src-gpui/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "code-gpui" +version = "3.0.1" +edition = "2021" +description = "2code desktop app rewritten in GPUI" +authors = ["AkaraChen"] + +[[bin]] +name = "2code" +path = "src/main.rs" + +[dependencies] +model = { path = "../src-tauri/crates/model" } +repo = { path = "../src-tauri/crates/repo" } +infra = { path = "../src-tauri/crates/infra" } +service = { path = "../src-tauri/crates/service" } + +gpui = "0.2.2" +gpui-component = "0.5.1" +diesel = { version = "2", features = ["sqlite"] } +anyhow = "1" +dirs = "6" +rfd = "0.15" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +vt100 = "0.16" +uuid = { version = "1", features = ["v4"] } +open = "5" + +[target.'cfg(target_os = "windows")'.dependencies] +libsqlite3-sys = { version = "0.37", features = ["bundled"] } diff --git a/src-gpui/README.md b/src-gpui/README.md new file mode 100644 index 00000000..8b72a05c --- /dev/null +++ b/src-gpui/README.md @@ -0,0 +1,28 @@ +# 2code GPUI + +Native desktop rewrite of 2code using [GPUI](https://gpui.rs) and [gpui-component](https://github.com/longbridge/gpui-component). The React/Tauri UI is still in the repo; this crate is the new primary shell and calls the existing `model` / `repo` / `service` / `infra` crates directly (no IPC). + +## Run + +```bash +# from repo root +cd src-gpui && cargo run + +# or +just gpui +``` + +Requires Rust 1.87+ (GPUI). On Linux install `libxkbcommon`, Vulkan, and Wayland/X11 development libraries. + +## Window contract + +- Main window `2code`: 1440×900, overlay title bar, traffic lights at (16, 24) +- Settings window `Settings`: 880×640, min 600×420 +- Same SQLite DB as Tauri: `$XDG_DATA_HOME/com.akrc.code/app.db` (or platform equivalent) +- Preferences: `gpui-prefs.json` next to the DB + +## Surfaces + +App sidebar, Home empty state, Profile workspace (top bar, Files/Git/Notes, unified tabs), PTY terminals (vt100, never destroyed on tab switch), file viewer (text / markdown / binary), command palette, Git panel + large diff dialog, all product dialogs, toasts, debug FAB, settings (6 tabs), en/zh i18n from `messages/*.json`. + +See `docs/ui-inventory.md` for the rewrite spec this crate implements. diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs new file mode 100644 index 00000000..da6956a5 --- /dev/null +++ b/src-gpui/src/app.rs @@ -0,0 +1,1336 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::time::Duration; + +use gpui::{ + div, prelude::*, px, Action, App, Context, Entity, FocusHandle, KeyBinding, Timer, Window, + WindowHandle, +}; + +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct OpenSettings; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct ToggleDebug; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct OpenPalette; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct NewTerminal; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct CloseActiveTab; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct ToggleProfileSidebar; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct OpenGitDiff; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct SaveFile; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct CommitChanges; +use gpui_component::input::{Input, InputState}; +use gpui_component::{Root, WindowExt}; +use crate::backend::{self, Backend}; +use crate::i18n::{self, Locale}; +use crate::prefs::{term_theme_by_name, Prefs, ThemePref}; +use crate::state::{ + AgentStatus, AppData, ContextMenu, DialogKind, DiffPreviewMode, GitDiffTab, NotesStatus, + OpenFileTab, OverlayState, Route, SettingsTab, SidebarMode, TermSession, ToastKind, TreeNode, + UnifiedTab, Workspace, +}; +use crate::ui; + +pub struct Inputs { + pub project_name: Entity, + pub profile_branch: Entity, + pub group_name: Entity, + pub rename: Entity, + pub commit_summary: Entity, + pub commit_body: Entity, + pub notes: Entity, + pub palette: Entity, + pub file_editor: Entity, + pub file_search: Entity, + pub debug_search: Entity, + pub branch_search: Entity, + pub new_path: Entity, + pub worktree: Entity, + pub init_script: Entity, + pub setup_script: Entity, + pub teardown_script: Entity, + pub template_name: Entity, + pub template_shell: Entity, + pub template_cwd: Entity, + pub template_commands: Entity, + pub custom_shell: Entity, + pub default_worktree: Entity, + pub term_search: Entity, +} + +pub struct AppView { + pub backend: Backend, + pub data: AppData, + pub inputs: Inputs, + pub focus: FocusHandle, + pub settings_window: Option>, +} + +impl AppView { + pub fn new(backend: Backend, window: &mut Window, cx: &mut Context) -> Self { + fn input( + window: &mut Window, + cx: &mut Context, + placeholder: &str, + multi: bool, + ) -> Entity { + let ph = placeholder.to_string(); + cx.new(|cx| { + let mut state = InputState::new(window, cx).placeholder(ph); + if multi { + state = state.multi_line(true); + } + state + }) + } + + let prefs = Prefs::load(&backend.app_data_dir); + let locale = prefs.language; + let data = AppData { + locale, + prefs: prefs.clone(), + projects: Vec::new(), + groups: Vec::new(), + route: Route::Home, + current_project: None, + current_profile: None, + workspaces: HashMap::new(), + overlay: OverlayState::default(), + toasts: Vec::new(), + toast_seq: 0, + sidebar_error: None, + }; + + let inputs = Inputs { + project_name: input(window, cx, "", false), + profile_branch: input(window, cx, "", false), + group_name: input(window, cx, "", false), + rename: input(window, cx, "", false), + commit_summary: input(window, cx, "", false), + commit_body: input(window, cx, "", true), + notes: input(window, cx, "", true), + palette: input(window, cx, "", false), + file_editor: input(window, cx, "", true), + file_search: input(window, cx, "", false), + debug_search: input(window, cx, "", false), + branch_search: input(window, cx, "", false), + new_path: input(window, cx, "", false), + worktree: input(window, cx, "", false), + init_script: input(window, cx, "", true), + setup_script: input(window, cx, "", true), + teardown_script: input(window, cx, "", true), + template_name: input(window, cx, "", false), + template_shell: input(window, cx, "", false), + template_cwd: input(window, cx, "", false), + template_commands: input(window, cx, "", true), + custom_shell: input(window, cx, "", false), + default_worktree: input(window, cx, "", false), + term_search: input(window, cx, "", false), + }; + + inputs.project_name.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "projectNamePlaceholderFolder"), window, cx); + }); + inputs.profile_branch.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "branchNamePlaceholder"), window, cx); + }); + inputs.group_name.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "projectGroupNamePlaceholder"), window, cx); + }); + inputs.commit_summary.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "gitCommitSummaryPlaceholder"), window, cx); + }); + inputs.commit_body.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "gitCommitBodyPlaceholder"), window, cx); + }); + inputs.notes.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "notesPlaceholder"), window, cx); + }); + inputs.palette.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "commandPalettePlaceholder"), window, cx); + }); + inputs.branch_search.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "searchBranchesPlaceholder"), window, cx); + }); + inputs.debug_search.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "debugSearchPlaceholder"), window, cx); + }); + inputs.term_search.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "terminalSearchPlaceholder"), window, cx); + }); + inputs.default_worktree.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "defaultWorktreeDirPlaceholder"), window, cx); + s.set_value(prefs.worktree_dir.clone(), window, cx); + }); + inputs.custom_shell.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "customShellPlaceholder"), window, cx); + s.set_value(prefs.custom_shell.clone(), window, cx); + }); + + let view = Self { + backend, + data, + inputs, + focus: cx.focus_handle(), + settings_window: None, + }; + + cx.spawn(async move |this, cx| loop { + Timer::after(Duration::from_millis(50)).await; + if this + .update(cx, |this, cx| { + this.tick(cx); + cx.notify(); + }) + .is_err() + { + break; + } + }) + .detach(); + + let mut view = view; + view.reload_projects(); + if view.data.projects.is_empty() { + view.data.overlay.onboarding = true; + } else if let Some((project_id, profile_id)) = view.data.projects.first().and_then(|first| { + first + .profiles + .iter() + .find(|p| p.is_default) + .or_else(|| first.profiles.first()) + .map(|p| (first.id.clone(), p.id.clone())) + }) { + view.open_profile(&project_id, &profile_id); + } + view + } + + pub fn t(&self, key: &str) -> String { + i18n::t(self.data.locale, key) + } + + pub fn tf(&self, key: &str, pairs: &[(&str, &str)]) -> String { + i18n::tf(self.data.locale, key, pairs) + } + + pub fn persist_prefs(&self) { + self.data.prefs.save(&self.backend.app_data_dir); + } + + pub fn apply_theme(&self, window: &mut Window, cx: &mut App) { + let mode = match self.data.prefs.theme { + ThemePref::Dark => gpui_component::ThemeMode::Dark, + ThemePref::Light => gpui_component::ThemeMode::Light, + ThemePref::System => cx.window_appearance().into(), + }; + gpui_component::Theme::change(mode, Some(window), cx); + } + + pub fn reload_projects(&mut self) { + match self.backend.list_projects() { + Ok(list) => { + self.data.projects = list; + self.data.sidebar_error = None; + } + Err(err) => self.data.sidebar_error = Some(err.to_string()), + } + self.data.groups = self.backend.list_groups().unwrap_or_default(); + } + + pub fn open_profile(&mut self, project_id: &str, profile_id: &str) { + self.data.current_project = Some(project_id.to_string()); + self.data.current_profile = Some(profile_id.to_string()); + self.data.route = Route::Workspace; + self.ensure_workspace(project_id, profile_id); + self.refresh_workspace(profile_id); + } + + fn ensure_workspace(&mut self, project_id: &str, profile_id: &str) { + if self.data.workspaces.contains_key(profile_id) { + return; + } + let project = self.data.project(project_id); + let profile = project.and_then(|p| p.profiles.iter().find(|pr| pr.id == profile_id)); + let Some(project) = project.cloned() else { + return; + }; + let Some(profile) = profile.cloned() else { + return; + }; + let branch = if profile.is_default { + self.backend + .git_branch(&profile.worktree_path) + .unwrap_or_else(|_| profile.branch_name.clone()) + } else { + profile.branch_name.clone() + }; + let config = self.backend.project_config(project_id).unwrap_or_default(); + self.data.workspaces.insert( + profile_id.to_string(), + Workspace { + project_id: project.id.clone(), + profile_id: profile.id.clone(), + branch, + worktree: profile.worktree_path.clone(), + project_name: project.name.clone(), + is_default: profile.is_default, + sidebar_mode: SidebarMode::Files, + sidebar_open: self.data.prefs.profile_sidebar_open, + terminals: Vec::new(), + files: Vec::new(), + active: None, + tree: HashMap::new(), + tree_error: None, + git_files: Vec::new(), + git_included: HashSet::new(), + git_stats: Default::default(), + git_ahead: 0, + notes: profile.notes.clone(), + notes_status: NotesStatus::Saved, + pr: None, + avatar: self.backend.github_avatar(project_id), + config, + }, + ); + self.load_tree_root(profile_id); + } + + pub fn refresh_workspace(&mut self, profile_id: &str) { + if let Some(ws) = self.data.workspaces.get_mut(profile_id) { + ws.git_stats = self.backend.git_diff_stats(profile_id).unwrap_or_default(); + ws.git_ahead = self.backend.git_ahead(profile_id).unwrap_or(0); + ws.git_files = self + .backend + .tree_git_status(profile_id) + .unwrap_or_default() + .into_iter() + .map(|e| (e.path, e.status)) + .collect(); + ws.git_included = ws.git_files.iter().map(|(p, _)| p.clone()).collect(); + ws.pr = self + .backend + .pr_status(&ws.worktree, Some(&ws.branch)) + .ok() + .flatten(); + if ws.is_default { + if let Ok(branch) = self.backend.git_branch(&ws.worktree) { + ws.branch = branch; + } + } + } + } + + pub fn load_tree_root(&mut self, profile_id: &str) { + self.load_tree_children(profile_id, None); + } + + pub fn load_tree_children(&mut self, profile_id: &str, parent: Option<&str>) { + let result = self.backend.list_tree_children(profile_id, parent); + let worktree = self + .data + .workspaces + .get(profile_id) + .map(|w| w.worktree.clone()) + .unwrap_or_default(); + let Some(ws) = self.data.workspaces.get_mut(profile_id) else { + return; + }; + match result { + Ok(paths) => { + ws.tree_error = None; + let mut names = Vec::new(); + for path in paths { + let abs = Path::new(&worktree).join(&path); + let is_dir = abs.is_dir(); + let name = backend::file_name(&path); + names.push(path.clone()); + ws.tree.entry(path.clone()).or_insert_with(|| TreeNode { + path: path.clone(), + name, + is_dir, + expanded: false, + children_loaded: false, + children: Vec::new(), + }); + } + if let Some(parent) = parent { + if let Some(node) = ws.tree.get_mut(parent) { + node.children = names; + node.children_loaded = true; + node.expanded = true; + } + } else { + ws.tree.insert( + String::new(), + TreeNode { + path: String::new(), + name: ".".into(), + is_dir: true, + expanded: true, + children_loaded: true, + children: names, + }, + ); + } + } + Err(err) => ws.tree_error = Some(err.to_string()), + } + } + + pub fn toggle_dir(&mut self, profile_id: &str, path: &str) { + let needs_load = self + .data + .workspaces + .get(profile_id) + .and_then(|w| w.tree.get(path)) + .map(|n| n.is_dir && !n.children_loaded) + .unwrap_or(false); + if needs_load { + self.load_tree_children(profile_id, Some(path)); + return; + } + if let Some(node) = self + .data + .workspaces + .get_mut(profile_id) + .and_then(|w| w.tree.get_mut(path)) + { + if node.is_dir { + node.expanded = !node.expanded; + } + } + } + + pub fn open_file(&mut self, profile_id: &str, path: &str, window: &mut Window, cx: &mut Context) { + let previewable = backend::is_previewable(path); + let content = if previewable { + String::new() + } else { + self.backend.read_file(profile_id, path).unwrap_or_default() + }; + let preview = if previewable { + self.backend.file_preview(profile_id, path).ok() + } else { + None + }; + let Some(ws) = self.data.workspaces.get_mut(profile_id) else { + return; + }; + if let Some(ix) = ws.files.iter().position(|f| f.path == path) { + ws.active = Some(UnifiedTab::File { index: ix }); + if !ws.files[ix].preview { + self.inputs.file_editor.update(cx, |s, cx| { + s.set_value(ws.files[ix].draft.clone(), window, cx); + }); + } + return; + } + let tab = OpenFileTab { + path: path.to_string(), + title: backend::file_name(path), + content: content.clone(), + draft: content.clone(), + preview: previewable, + preview_kind: preview + .as_ref() + .map(|p| p.kind.clone()) + .unwrap_or_default(), + binary_note: preview.map(|p| p.mime_type).unwrap_or_default(), + }; + ws.files.push(tab); + ws.active = Some(UnifiedTab::File { + index: ws.files.len() - 1, + }); + if !previewable { + self.inputs.file_editor.update(cx, |s, cx| { + s.set_value(content, window, cx); + }); + } + } + + pub fn save_active_file(&mut self, window: &mut Window, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let draft = self.inputs.file_editor.read(cx).value().to_string(); + let (title, path) = { + let Some(ws) = self.data.workspaces.get_mut(&profile_id) else { + return; + }; + let Some(file) = ws.active_file_mut() else { + return; + }; + if file.preview { + return; + } + file.draft = draft.clone(); + (file.title.clone(), file.path.clone()) + }; + match self.backend.write_file(&profile_id, &path, &draft) { + Ok(()) => { + if let Some(file) = self + .data + .workspaces + .get_mut(&profile_id) + .and_then(|w| w.active_file_mut()) + { + file.content = draft; + file.draft = file.content.clone(); + } + self.data + .push_toast(ToastKind::Success, self.t("save"), title); + } + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()), + } + let _ = window; + } + + pub fn request_close_file(&mut self, profile_id: &str, path: &str) { + let dirty = self + .data + .workspaces + .get(profile_id) + .and_then(|w| w.files.iter().find(|f| f.path == path)) + .map(|f| f.dirty()) + .unwrap_or(false); + if dirty { + self.data.overlay.dialog = Some(DialogKind::CloseUnsaved); + self.data.overlay.dialog_file = Some(path.to_string()); + self.data.overlay.pending_close_file = Some(path.to_string()); + } else { + self.close_file(profile_id, path); + } + } + + pub fn close_file(&mut self, profile_id: &str, path: &str) { + if let Some(ws) = self.data.workspaces.get_mut(profile_id) { + if let Some(ix) = ws.files.iter().position(|f| f.path == path) { + ws.files.remove(ix); + ws.active = if !ws.terminals.is_empty() { + Some(UnifiedTab::Terminal { index: 0 }) + } else if !ws.files.is_empty() { + Some(UnifiedTab::File { index: 0 }) + } else { + None + }; + } + } + } + + pub fn create_terminal(&mut self, title: &str, cwd: &str, commands: Vec) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let Some(ws) = self.data.workspaces.get(&profile_id) else { + return; + }; + let worktree = ws.worktree.clone(); + let cwd_abs = if cwd.is_empty() { + worktree + } else { + Path::new(&worktree).join(cwd).to_string_lossy().into_owned() + }; + let shell = self.data.prefs.effective_shell(); + let title = if title.is_empty() { + self.t("terminal") + } else { + title.to_string() + }; + match self + .backend + .create_terminal(&profile_id, &title, &cwd_abs, &shell, commands) + { + Ok(id) => { + if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { + ws.terminals + .push(TermSession::new(id, title, profile_id.clone())); + ws.active = Some(UnifiedTab::Terminal { + index: ws.terminals.len() - 1, + }); + } + } + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()), + } + } + + pub fn close_terminal(&mut self, profile_id: &str, session_id: &str) { + let _ = self.backend.close_terminal(session_id); + if let Some(ws) = self.data.workspaces.get_mut(profile_id) { + ws.terminals.retain(|t| t.id != session_id); + ws.active = if !ws.terminals.is_empty() { + Some(UnifiedTab::Terminal { index: 0 }) + } else if !ws.files.is_empty() { + Some(UnifiedTab::File { index: 0 }) + } else { + None + }; + } + } + + pub fn write_to_active_pty(&mut self, bytes: &[u8]) { + if let Some(ws) = self.data.current_ws() { + if let Some(UnifiedTab::Terminal { index }) = ws.active { + if let Some(term) = ws.terminals.get(index) { + let _ = self.backend.write_pty(&term.id, bytes); + } + } + } + } + + pub fn tick(&mut self, _cx: &mut Context) { + self.data.expire_toasts(); + let ids: Vec<(String, String)> = self + .data + .workspaces + .iter() + .flat_map(|(pid, ws)| { + ws.terminals + .iter() + .map(|t| (pid.clone(), t.id.clone())) + .collect::>() + }) + .collect(); + for (profile_id, session_id) in ids { + let bytes = self.backend.take_output(&session_id); + if bytes.is_empty() { + continue; + } + if let Some(term) = self + .data + .workspaces + .get_mut(&profile_id) + .and_then(|w| w.terminals.iter_mut().find(|t| t.id == session_id)) + { + term.feed(&bytes); + } + } + if let Ok(exits) = self.backend.exits.lock() { + let _ = exits.len(); + } + } + + pub fn create_project_from_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let folder = match self.data.overlay.dialog_folder.clone() { + Some(f) => f, + None => return, + }; + let name = self.inputs.project_name.read(cx).value().to_string(); + let name = if name.trim().is_empty() { + backend::file_name(&folder) + } else { + name + }; + self.data.overlay.dialog_busy = true; + match self.backend.create_project(&name, &folder) { + Ok(project) => { + self.reload_projects(); + self.data.overlay.dialog = None; + self.data.overlay.dialog_busy = false; + self.data.overlay.onboarding = false; + if let Some(profile) = self.data.default_profile_of(&project.id) { + self.open_profile(&project.id, &profile.id); + } + } + Err(err) => { + self.data.overlay.dialog_busy = false; + self.data.overlay.dialog_error = Some(err.to_string()); + } + } + let _ = window; + } + + pub fn delete_current_dialog_project(&mut self) { + let Some(id) = self.data.overlay.dialog_project.clone() else { + return; + }; + if let Err(err) = self.backend.delete_project(&id) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + let was_current = self.data.current_project.as_deref() == Some(id.as_str()); + self.reload_projects(); + self.data.overlay.dialog = None; + if was_current { + if let Some(next) = self.data.projects.first().cloned() { + if let Some(profile) = self.data.default_profile_of(&next.id) { + self.open_profile(&next.id, &profile.id); + return; + } + } + self.data.route = Route::Home; + self.data.current_project = None; + self.data.current_profile = None; + } + } + + pub fn rename_dialog_project(&mut self, cx: &mut Context) { + let Some(id) = self.data.overlay.dialog_project.clone() else { + return; + }; + let name = self.inputs.rename.read(cx).value().to_string(); + if name.trim().is_empty() { + return; + } + if let Err(err) = self.backend.rename_project(&id, &name) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + self.reload_projects(); + self.data.overlay.dialog = None; + } + + pub fn create_profile_from_dialog(&mut self, cx: &mut Context) { + let Some(project_id) = self.data.overlay.dialog_project.clone() else { + return; + }; + let branch = self.inputs.profile_branch.read(cx).value().to_string(); + match self.backend.create_profile(&project_id, branch.trim()) { + Ok(profile) => { + self.reload_projects(); + self.data.overlay.dialog = None; + self.open_profile(&project_id, &profile.id); + } + Err(err) => self.data.overlay.dialog_error = Some(err.to_string()), + } + } + + pub fn delete_dialog_profile(&mut self) { + let Some(id) = self.data.overlay.dialog_profile.clone() else { + return; + }; + let was_current = self.data.current_profile.as_deref() == Some(id.as_str()); + let project_id = self.data.current_project.clone(); + if let Err(err) = self.backend.delete_profile(&id) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + self.data.workspaces.remove(&id); + self.reload_projects(); + self.data.overlay.dialog = None; + if was_current { + if let Some(project_id) = project_id { + if let Some(profile) = self.data.default_profile_of(&project_id) { + self.open_profile(&project_id, &profile.id); + return; + } + } + self.data.route = Route::Home; + } + } + + pub fn prepare_delete_profile(&mut self, id: &str) { + self.data.overlay.dialog = Some(DialogKind::DeleteProfile); + self.data.overlay.dialog_profile = Some(id.to_string()); + self.data.overlay.dialog_busy = true; + self.data.overlay.delete_warning = None; + match self.backend.delete_profile_check(id) { + Ok(check) => { + self.data.overlay.dialog_busy = false; + let mut parts = Vec::new(); + if check.working_tree_diff.files_changed > 0 { + parts.push(i18n::tf( + self.data.locale, + "deleteProfileLocalChangesWarning", + &[ + ("files", &check.working_tree_diff.files_changed.to_string()), + ("insertions", &check.working_tree_diff.insertions.to_string()), + ("deletions", &check.working_tree_diff.deletions.to_string()), + ], + )); + } + if check.unpushed_commit_count > 0 { + parts.push(i18n::tf( + self.data.locale, + "deleteProfileUnpushedCommitsWarning", + &[ + ("count", &check.unpushed_commit_count.to_string()), + ("files", &check.unpushed_commit_diff.files_changed.to_string()), + ("insertions", &check.unpushed_commit_diff.insertions.to_string()), + ("deletions", &check.unpushed_commit_diff.deletions.to_string()), + ], + )); + } + if !parts.is_empty() { + self.data.overlay.delete_warning = Some(parts.join("\n")); + } + } + Err(err) => { + self.data.overlay.dialog_busy = false; + self.data.overlay.delete_warning = Some(format!( + "{}\n{}", + self.t("deleteProfileGitCheckFailedTitle"), + err + )); + } + } + } + + pub fn commit_selected(&mut self, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let summary = self.inputs.commit_summary.read(cx).value().to_string(); + let body = self.inputs.commit_body.read(cx).value().to_string(); + let Some(ws) = self.data.workspaces.get(&profile_id) else { + return; + }; + let files: Vec = ws.git_included.iter().cloned().collect(); + if summary.trim().is_empty() || files.is_empty() { + return; + } + let body = if body.trim().is_empty() { + None + } else { + Some(body.as_str()) + }; + match self.backend.commit_changes(&profile_id, &files, &summary, body) { + Ok(hash) => { + self.data.push_toast( + ToastKind::Success, + self.t("gitCommitSuccessTitle"), + i18n::tf( + self.data.locale, + "gitCommitSuccessDescription", + &[("hash", &hash)], + ), + ); + self.refresh_workspace(&profile_id); + } + Err(err) => self.data.push_toast( + ToastKind::Error, + self.t("gitCommitErrorTitle"), + err.to_string(), + ), + } + } + + pub fn push_current(&mut self) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + match self.backend.git_push(&profile_id) { + Ok(()) => { + self.data + .push_toast(ToastKind::Success, self.t("gitPushSuccessTitle"), ""); + self.refresh_workspace(&profile_id); + } + Err(err) => self.data.push_toast( + ToastKind::Error, + self.t("gitPushErrorTitle"), + err.to_string(), + ), + } + } + + pub fn discard_file(&mut self, path: &str) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + match self.backend.discard_file(&profile_id, &[path.to_string()]) { + Ok(()) => { + self.data.push_toast( + ToastKind::Success, + self.t("gitDiscardFileSuccessTitle"), + i18n::tf( + self.data.locale, + "gitDiscardFileSuccessDescription", + &[("file", path)], + ), + ); + self.refresh_workspace(&profile_id); + } + Err(err) => self.data.push_toast( + ToastKind::Error, + self.t("gitDiscardFileErrorTitle"), + err.to_string(), + ), + } + } + + pub fn open_git_diff(&mut self) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + self.data.overlay.git_diff_open = true; + self.data.overlay.git_diff_tab = GitDiffTab::Changes; + self.data.overlay.git_diff_text = self.backend.git_diff(&profile_id).unwrap_or_default(); + self.data.overlay.git_commits = self.backend.git_log(&profile_id, 80).unwrap_or_default(); + self.refresh_workspace(&profile_id); + } + + pub fn select_diff_file(&mut self, path: &str) { + self.data.overlay.git_diff_file = Some(path.to_string()); + if let Some(profile_id) = self.data.current_profile.clone() { + self.data.overlay.git_diff_text = self.backend.git_diff(&profile_id).unwrap_or_default(); + } + } + + pub fn select_commit(&mut self, hash: &str) { + self.data.overlay.git_selected_commit = Some(hash.to_string()); + if let Some(profile_id) = self.data.current_profile.clone() { + let diff = self.backend.commit_diff(&profile_id, hash).unwrap_or_default(); + self.data.overlay.git_commit_files = parse_diff_files(&diff); + self.data.overlay.git_diff_text = diff; + } + } + + pub fn checkout_branch(&mut self, name: &str) { + let Some(ws) = self.data.current_ws() else { + return; + }; + let folder = ws.worktree.clone(); + match self.backend.checkout_branch(&folder, name) { + Ok(()) => { + self.data.push_toast( + ToastKind::Success, + i18n::tf(self.data.locale, "gitCheckoutSuccessTitle", &[("branch", name)]), + "", + ); + if let Some(pid) = self.data.current_profile.clone() { + self.refresh_workspace(&pid); + } + self.data.overlay.dialog = None; + } + Err(err) => self.data.push_toast( + ToastKind::Error, + self.t("gitCheckoutErrorTitle"), + err.to_string(), + ), + } + } + + pub fn save_notes(&mut self, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let notes = self.inputs.notes.read(cx).value().to_string(); + if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { + ws.notes_status = NotesStatus::Saving; + ws.notes = notes.clone(); + } + match self.backend.update_notes(&profile_id, ¬es) { + Ok(()) => { + if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { + ws.notes_status = NotesStatus::Saved; + } + } + Err(_) => { + if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { + ws.notes_status = NotesStatus::Failed; + } + self.data + .push_toast(ToastKind::Error, self.t("notesSaveFailedTitle"), ""); + } + } + } + + pub fn save_project_settings(&mut self, cx: &mut Context) { + let Some(project_id) = self + .data + .overlay + .dialog_project + .clone() + .or_else(|| self.data.current_project.clone()) + else { + return; + }; + let mut config = self.backend.project_config(&project_id).unwrap_or_default(); + let worktree = self.inputs.worktree.read(cx).value().to_string(); + config.worktree_dir = if worktree.trim().is_empty() { + None + } else { + Some(worktree) + }; + config.init_script = lines(&self.inputs.init_script.read(cx).value()); + config.setup_script = lines(&self.inputs.setup_script.read(cx).value()); + config.teardown_script = lines(&self.inputs.teardown_script.read(cx).value()); + if let Err(err) = self.backend.save_project_config(&project_id, &config) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + if let Some(ws) = self + .data + .workspaces + .values_mut() + .find(|w| w.project_id == project_id) + { + ws.config = config; + } + self.data.overlay.dialog = None; + } + + pub fn search_palette(&mut self, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let q = self.inputs.palette.read(cx).value().to_string(); + if q.trim().is_empty() { + self.data.overlay.palette_results.clear(); + return; + } + self.data.overlay.palette_results = self + .backend + .search_files(&profile_id, &q) + .unwrap_or_default(); + self.data.overlay.palette_index = 0; + } + + pub fn open_palette_selection(&mut self, window: &mut Window, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let ix = self.data.overlay.palette_index; + let path = self + .data + .overlay + .palette_results + .get(ix) + .map(|r| r.path.clone()); + if let Some(path) = path { + self.data.overlay.palette_open = false; + self.open_file(&profile_id, &path, window, cx); + } + } + + pub fn create_path(&mut self, is_dir: bool, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let name = self.inputs.new_path.read(cx).value().to_string(); + let name = if name.trim().is_empty() { + if is_dir { + "New Folder".into() + } else { + "New File".into() + } + } else { + name + }; + if let Err(err) = self.backend.create_path(&profile_id, &name, is_dir) { + self.data.push_toast( + ToastKind::Error, + self.t("fileTreeCreateErrorTitle"), + err.to_string(), + ); + return; + } + self.load_tree_root(&profile_id); + } + + pub fn delete_tree_path(&mut self, path: &str) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + if let Err(err) = self.backend.delete_paths(&profile_id, &[path.to_string()]) { + self.data.push_toast( + ToastKind::Error, + self.t("fileTreeDeleteErrorTitle"), + err.to_string(), + ); + return; + } + self.load_tree_root(&profile_id); + } + + pub fn reveal(&mut self, path: Option<&str>) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let _ = self.backend.reveal_path(&profile_id, path); + } + + pub fn open_external(&mut self, path: &str) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let _ = self.backend.open_in_default_app(&profile_id, path); + } + + pub fn copy_path(&self, path: &str, absolute: bool, cx: &mut App) { + let abs = self + .data + .current_ws() + .map(|w| Path::new(&w.worktree).join(path).to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()); + let text = if absolute { abs } else { path.to_string() }; + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); + } + + pub fn open_topbar_app(&self, kind: &str) { + let Some(ws) = self.data.current_ws() else { + return; + }; + let folder = &ws.worktree; + let cmd = match kind { + "github-desktop" => "github", + "editor" => match self.data.prefs.editor_app.as_str() { + "cursor" => "cursor", + "windsurf" => "windsurf", + "zed" => "zed", + "sublime" => "subl", + _ => "code", + }, + "terminal" => match self.data.prefs.terminal_app.as_str() { + "ghostty" => "ghostty", + "iterm2" => "open", + "kitty" => "kitty", + "warp" => "warp", + _ => "x-terminal-emulator", + }, + _ => return, + }; + let _ = std::process::Command::new(cmd).arg(folder).spawn(); + } + + pub fn open_pr(&self) { + if let Some(url) = self.data.current_ws().and_then(|w| w.pr.as_ref().map(|p| p.url.clone())) { + let _ = open::that(url); + } + } + + pub fn current_term_theme(&self) -> &'static crate::prefs::TermTheme { + let dark = window_is_dark_pref(&self.data.prefs); + let name = if self.data.prefs.sync_terminal_theme { + &self.data.prefs.terminal_theme_dark + } else if dark { + &self.data.prefs.terminal_theme_dark + } else { + &self.data.prefs.terminal_theme_light + }; + term_theme_by_name(name) + } + + pub fn agent_for_project(&self, project_id: &str) -> AgentStatus { + self.data + .project(project_id) + .and_then(|p| p.profiles.iter().find(|pr| pr.is_default)) + .and_then(|pr| self.data.workspaces.get(&pr.id)) + .and_then(|ws| ws.terminals.iter().map(|t| t.agent).max_by_key(|a| agent_rank(*a))) + .unwrap_or(AgentStatus::Idle) + } + + pub fn agent_for_profile(&self, profile_id: &str) -> AgentStatus { + self.data + .workspaces + .get(profile_id) + .and_then(|ws| ws.terminals.iter().map(|t| t.agent).max_by_key(|a| agent_rank(*a))) + .unwrap_or(AgentStatus::Idle) + } + + pub fn bind_keys(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("cmd-,", OpenSettings, None), + KeyBinding::new("ctrl-,", OpenSettings, None), + KeyBinding::new("cmd-shift-d", ToggleDebug, None), + KeyBinding::new("ctrl-shift-d", ToggleDebug, None), + KeyBinding::new("cmd-k", OpenPalette, None), + KeyBinding::new("ctrl-k", OpenPalette, None), + KeyBinding::new("cmd-t", NewTerminal, None), + KeyBinding::new("ctrl-t", NewTerminal, None), + KeyBinding::new("cmd-w", CloseActiveTab, None), + KeyBinding::new("ctrl-w", CloseActiveTab, None), + KeyBinding::new("cmd-e", ToggleProfileSidebar, None), + KeyBinding::new("ctrl-e", ToggleProfileSidebar, None), + KeyBinding::new("cmd-g", OpenGitDiff, None), + KeyBinding::new("ctrl-g", OpenGitDiff, None), + KeyBinding::new("cmd-s", SaveFile, None), + KeyBinding::new("ctrl-s", SaveFile, None), + KeyBinding::new("cmd-enter", CommitChanges, None), + KeyBinding::new("ctrl-enter", CommitChanges, None), + ]); + } +} + +fn agent_rank(status: AgentStatus) -> u8 { + match status { + AgentStatus::Running => 3, + AgentStatus::Waiting => 2, + AgentStatus::Completed => 1, + AgentStatus::Idle => 0, + } +} + +fn window_is_dark_pref(prefs: &Prefs) -> bool { + matches!(prefs.theme, ThemePref::Dark) +} + +fn lines(s: &str) -> Vec { + s.lines() + .map(|l| l.trim_end().to_string()) + .filter(|l| !l.is_empty()) + .collect() +} + +pub fn parse_diff_files(diff: &str) -> Vec { + let mut files = Vec::new(); + for line in diff.lines() { + if let Some(rest) = line.strip_prefix("diff --git ") { + if let Some((_, b)) = rest.split_once(' ') { + files.push(b.trim_start_matches("b/").to_string()); + } + } + } + files +} + +pub fn file_status_badge(status: &str) -> &'static str { + let s = status.to_ascii_uppercase(); + if s.contains('A') || s.contains('?') { + "A" + } else if s.contains('D') { + "D" + } else if s.contains('R') { + "R" + } else { + "M" + } +} + +pub fn extract_file_hunk(diff: &str, path: &str) -> String { + let marker = format!("b/{path}"); + let mut out = String::new(); + let mut take = false; + for line in diff.lines() { + if line.starts_with("diff --git ") { + take = line.contains(&marker) || line.ends_with(path); + if take { + out.push_str(line); + out.push('\n'); + } + continue; + } + if take { + out.push_str(line); + out.push('\n'); + } + } + if out.is_empty() { + diff.to_string() + } else { + out + } +} + +impl gpui::Render for AppView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.apply_theme(window, cx); + if self.data.overlay.palette_open { + let q = self.inputs.palette.read(cx).value().to_string(); + if self.data.overlay.palette_results.is_empty() && !q.is_empty() { + self.search_palette(cx); + } + } + if matches!(self.data.current_ws().map(|w| w.sidebar_mode), Some(SidebarMode::Notes)) { + // keep notes editor mounted + } + if let Some(ws) = self.data.current_ws() { + if let Some(UnifiedTab::File { index }) = ws.active { + if let Some(file) = ws.files.get(index) { + if !file.preview { + let live = self.inputs.file_editor.read(cx).value().to_string(); + if let Some(ws) = self.data.current_ws_mut() { + if let Some(file) = ws.active_file_mut() { + file.draft = live; + } + } + } + } + } + } + + div() + .id("app-root") + .track_focus(&self.focus) + .key_context("App") + .size_full() + .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { + ui::settings::open_settings_window(this, window, cx); + })) + .on_action(cx.listener(|this, _: &ToggleDebug, _, cx| { + this.data.prefs.debug_mode = !this.data.prefs.debug_mode; + this.data.overlay.debug_open = this.data.prefs.debug_mode; + this.persist_prefs(); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &OpenPalette, _, cx| { + if this.data.route == Route::Workspace { + this.data.overlay.palette_open = !this.data.overlay.palette_open; + cx.notify(); + } + })) + .on_action(cx.listener(|this, _: &NewTerminal, _, cx| { + this.create_terminal(&this.t("newTerminal"), "", Vec::new()); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &CloseActiveTab, _, cx| { + if let Some(profile_id) = this.data.current_profile.clone() { + if let Some(ws) = this.data.workspaces.get(&profile_id) { + match ws.active { + Some(UnifiedTab::Terminal { index }) => { + if let Some(id) = ws.terminals.get(index).map(|t| t.id.clone()) { + this.close_terminal(&profile_id, &id); + } + } + Some(UnifiedTab::File { index }) => { + if let Some(path) = ws.files.get(index).map(|f| f.path.clone()) { + this.request_close_file(&profile_id, &path); + } + } + None => {} + } + } + } + cx.notify(); + })) + .on_action(cx.listener(|this, _: &ToggleProfileSidebar, _, cx| { + if let Some(ws) = this.data.current_ws_mut() { + ws.sidebar_open = !ws.sidebar_open; + this.data.prefs.profile_sidebar_open = ws.sidebar_open; + this.persist_prefs(); + } + cx.notify(); + })) + .on_action(cx.listener(|this, _: &OpenGitDiff, _, cx| { + this.open_git_diff(); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &SaveFile, window, cx| { + this.save_active_file(window, cx); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &CommitChanges, _, cx| { + this.commit_selected(cx); + cx.notify(); + })) + .child(ui::shell::render(self, window, cx)) + } +} + +#[allow(dead_code)] +pub fn input_el(state: &Entity) -> Input { + Input::new(state) +} diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs new file mode 100644 index 00000000..73f7ad24 --- /dev/null +++ b/src-gpui/src/backend.rs @@ -0,0 +1,466 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{mpsc, Arc, Mutex}; + +use infra::db::{DbPool, init_db}; +use infra::pty::{self as pty_infra, PtyReadThreads, PtySessionMap}; +use model::error::AppError; +use model::filesystem::{FilePreview, FileSearchResult, FileTreeGitStatusEntry}; +use model::profile::{Profile, ProfileDeleteCheck}; +use model::project::{ + GitBranchInfo, GitCommit, GitDiffSnapshot, GitDiffStats, GitPullRequestStatus, + Project, ProjectConfig, ProjectSidebarLayoutUpdate, ProjectWithProfiles, +}; +use model::project_group::ProjectGroup; +use model::pty::{PtyConfig, PtySessionMeta, PtySessionRecord}; +use service::pty::{PtyContext, PtyFlushSenders, PtyLogDir}; +use service::{PtyEventEmitter, WatchEventSender}; + +pub type TerminalBuffers = Arc>>>; +pub type TerminalExits = Arc>>; + +pub struct GpuiPtyEmitter { + buffers: TerminalBuffers, + exits: TerminalExits, +} + +impl PtyEventEmitter for GpuiPtyEmitter { + fn emit_output(&self, session_id: &str, bytes: &[u8]) -> bool { + if let Ok(mut map) = self.buffers.lock() { + map.entry(session_id.to_string()) + .or_default() + .extend_from_slice(bytes); + } + true + } + + fn emit_exit(&self, session_id: &str) { + if let Ok(mut exits) = self.exits.lock() { + exits.push(session_id.to_string()); + } + } +} + +struct NoopWatchSender; + +impl WatchEventSender for NoopWatchSender { + fn send(&self, _event: model::watcher::WatchEvent) -> bool { + true + } +} + +#[derive(Clone)] +pub struct Backend { + pub db: DbPool, + pub app_data_dir: PathBuf, + pub sessions: PtySessionMap, + pub flush_senders: PtyFlushSenders, + pub read_threads: PtyReadThreads, + pub output_dir: PathBuf, + pub buffers: TerminalBuffers, + pub exits: TerminalExits, +} + +impl Backend { + pub fn init() -> Result { + let app_data_dir = app_data_dir(); + std::fs::create_dir_all(&app_data_dir) + .map_err(|e| format!("create app data dir: {e}"))?; + + let db = init_db(&app_data_dir)?; + service::pty::mark_all_closed(&db); + let output_dir = infra::pty_log::logs_dir(&app_data_dir); + service::pty::gc_orphan_logs(&db, &output_dir); + + let buffers = Arc::new(Mutex::new(HashMap::new())); + let exits = Arc::new(Mutex::new(Vec::new())); + + Ok(Self { + db, + app_data_dir, + sessions: pty_infra::create_session_map(), + flush_senders: service::pty::create_flush_senders(), + read_threads: pty_infra::create_thread_tracker(), + output_dir, + buffers, + exits, + }) + } + + fn pty_ctx(&self) -> PtyContext { + PtyContext { + db: self.db.clone(), + sessions: self.sessions.clone(), + flush_senders: self.flush_senders.clone(), + read_threads: self.read_threads.clone(), + emitter: Arc::new(GpuiPtyEmitter { + buffers: self.buffers.clone(), + exits: self.exits.clone(), + }), + output_dir: self.output_dir.clone(), + } + } + + fn with_db(&self, f: impl FnOnce(&mut diesel::sqlite::SqliteConnection) -> Result) -> Result { + let mut conn = self.db.lock().map_err(|_| AppError::LockError)?; + f(&mut conn) + } + + pub fn list_projects(&self) -> Result, AppError> { + self.with_db(service::project::list) + } + + pub fn list_groups(&self) -> Result, AppError> { + self.with_db(service::project::list_groups) + } + + pub fn create_project(&self, name: &str, folder: &str) -> Result { + self.with_db(|conn| service::project::create_from_folder(conn, name, folder)) + } + + pub fn rename_project(&self, id: &str, name: &str) -> Result { + self.with_db(|conn| { + service::project::update(conn, id, Some(name.to_string()), None) + }) + } + + pub fn delete_project(&self, id: &str) -> Result<(), AppError> { + service::project::delete_with_context(&self.pty_ctx(), id) + } + + pub fn create_group(&self, name: &str) -> Result { + self.with_db(|conn| service::project::create_group(conn, name)) + } + + pub fn assign_to_group( + &self, + project_id: &str, + group_id: Option, + ) -> Result { + self.with_db(|conn| service::project::assign_to_group(conn, project_id, group_id)) + } + + pub fn update_sidebar_layout( + &self, + updates: Vec, + ) -> Result<(), AppError> { + self.with_db(|conn| service::project::update_sidebar_layout(conn, updates)) + } + + pub fn create_profile(&self, project_id: &str, branch_name: &str) -> Result { + self.with_db(|conn| service::profile::create(conn, project_id, branch_name)) + } + + pub fn delete_profile(&self, id: &str) -> Result<(), AppError> { + service::profile::delete_with_context(&self.pty_ctx(), id) + } + + pub fn delete_profile_check(&self, id: &str) -> Result { + self.with_db(|conn| service::profile::delete_check(conn, id)) + } + + pub fn update_notes(&self, profile_id: &str, notes: &str) -> Result<(), AppError> { + self.with_db(|conn| repo::profile::update_notes(conn, profile_id, notes).map(|_| ())) + } + + pub fn git_branch(&self, folder: &str) -> Result { + service::project::get_branch(folder) + } + + pub fn git_diff_stats(&self, profile_id: &str) -> Result { + self.with_db(|conn| service::project::get_diff_stats(conn, profile_id)) + } + + pub fn git_diff(&self, profile_id: &str) -> Result { + self.with_db(|conn| service::project::get_diff(conn, profile_id)) + } + + pub fn git_diff_snapshot(&self, profile_id: &str) -> Result { + self.with_db(|conn| { + let profile = repo::profile::find_by_id(conn, profile_id)?; + infra::git::diff_snapshot(&profile.worktree_path) + }) + } + + pub fn git_log(&self, profile_id: &str, limit: u32) -> Result, AppError> { + self.with_db(|conn| service::project::get_log(conn, profile_id, limit)) + } + + pub fn commit_diff(&self, profile_id: &str, hash: &str) -> Result { + self.with_db(|conn| service::project::get_commit_diff(conn, profile_id, hash)) + } + + pub fn commit_changes( + &self, + profile_id: &str, + files: &[String], + message: &str, + body: Option<&str>, + ) -> Result { + self.with_db(|conn| service::project::commit_changes(conn, profile_id, files, message, body)) + } + + pub fn discard_file(&self, profile_id: &str, paths: &[String]) -> Result<(), AppError> { + self.with_db(|conn| service::project::discard_file_changes(conn, profile_id, paths)) + } + + pub fn git_ahead(&self, profile_id: &str) -> Result { + self.with_db(|conn| service::project::get_ahead_count(conn, profile_id)) + } + + pub fn git_push(&self, profile_id: &str) -> Result<(), AppError> { + self.with_db(|conn| service::project::push(conn, profile_id)) + } + + pub fn list_branches(&self, folder: &str) -> Result, AppError> { + infra::git::list_branches(folder) + } + + pub fn checkout_branch(&self, folder: &str, branch: &str) -> Result<(), AppError> { + infra::git::checkout_branch(folder, branch) + } + + pub fn pr_status( + &self, + folder: &str, + branch: Option<&str>, + ) -> Result, AppError> { + service::project::get_pull_request_status_for_folder(folder, branch) + } + + pub fn project_config(&self, project_id: &str) -> Result { + let folder = self.with_db(|conn| { + Ok(repo::project::find_by_id(conn, project_id)?.folder) + })?; + Ok(infra::config::load_project_config(&folder).unwrap_or_default()) + } + + pub fn save_project_config( + &self, + project_id: &str, + config: &ProjectConfig, + ) -> Result<(), AppError> { + let folder = self.with_db(|conn| { + Ok(repo::project::find_by_id(conn, project_id)?.folder) + })?; + infra::config::write_project_config(&folder, config) + } + + pub fn github_avatar(&self, project_id: &str) -> Option { + self.with_db(|conn| { + let folder = repo::project::find_by_id(conn, project_id)?.folder; + Ok(infra::git::github_avatar_url(&folder)) + }) + .ok() + .flatten() + } + + pub fn list_tree_children( + &self, + profile_id: &str, + relative: Option<&str>, + ) -> Result, AppError> { + service::filesystem::list_file_tree_child_paths(&self.db, profile_id, relative) + } + + pub fn tree_git_status(&self, profile_id: &str) -> Result, AppError> { + self.with_db(|conn| service::filesystem::get_file_tree_git_status(conn, profile_id)) + } + + pub fn search_files(&self, profile_id: &str, query: &str) -> Result, AppError> { + self.with_db(|conn| service::filesystem::search_file(conn, profile_id, query)) + } + + pub fn read_file(&self, profile_id: &str, path: &str) -> Result { + service::filesystem::read_file_content(&self.db, profile_id, path) + } + + pub fn write_file(&self, profile_id: &str, path: &str, content: &str) -> Result<(), AppError> { + service::filesystem::write_file_content(&self.db, profile_id, path, content) + } + + pub fn file_preview(&self, profile_id: &str, path: &str) -> Result { + let file_cache = self.app_data_dir.join("file-preview"); + let office_cache = self.app_data_dir.join("office-preview"); + let _ = std::fs::create_dir_all(&file_cache); + let _ = std::fs::create_dir_all(&office_cache); + service::filesystem::get_file_preview( + &self.db, + profile_id, + path, + &file_cache, + &office_cache, + ) + } + + pub fn create_path(&self, profile_id: &str, path: &str, is_dir: bool) -> Result<(), AppError> { + service::filesystem::create_file_tree_path( + &self.db, + profile_id, + path, + if is_dir { "directory" } else { "file" }, + ) + } + + pub fn rename_path(&self, profile_id: &str, from: &str, to: &str) -> Result<(), AppError> { + service::filesystem::rename_file_tree_path(&self.db, profile_id, from, to) + } + + pub fn delete_paths(&self, profile_id: &str, paths: &[String]) -> Result<(), AppError> { + service::filesystem::delete_file_tree_paths(&self.db, profile_id, paths) + } + + pub fn reveal_path(&self, profile_id: &str, path: Option<&str>) -> Result<(), AppError> { + service::filesystem::reveal_path_in_file_manager(&self.db, profile_id, path) + } + + pub fn open_in_default_app(&self, profile_id: &str, path: &str) -> Result<(), AppError> { + service::filesystem::open_path_in_default_app(&self.db, profile_id, path) + } + + pub fn create_terminal( + &self, + profile_id: &str, + title: &str, + cwd: &str, + shell: &str, + startup_commands: Vec, + ) -> Result { + let ctx = self.pty_ctx(); + service::pty::create_session( + &ctx, + &PtySessionMeta { + profile_id: profile_id.to_string(), + title: title.to_string(), + }, + &PtyConfig { + shell: shell.to_string(), + cwd: cwd.to_string(), + rows: 32, + cols: 120, + startup_commands, + }, + ) + } + + pub fn write_pty(&self, session_id: &str, data: &[u8]) -> Result<(), AppError> { + pty_infra::write_to_pty(&self.sessions, session_id, data) + } + + pub fn resize_pty(&self, session_id: &str, rows: u16, cols: u16) -> Result<(), AppError> { + pty_infra::resize_pty(&self.sessions, session_id, rows, cols) + } + + pub fn close_terminal(&self, session_id: &str) -> Result<(), AppError> { + service::pty::close_session_full( + &self.sessions, + &self.flush_senders, + &self.output_dir, + session_id, + )?; + self.with_db(|conn| { + repo::pty::mark_closed(conn, session_id); + Ok(()) + }) + } + + pub fn find_project(&self, id: &str) -> Result { + self.with_db(|conn| repo::project::find_by_id(conn, id)) + } + + pub fn find_profile(&self, id: &str) -> Result { + self.with_db(|conn| repo::profile::find_by_id(conn, id)) + } + + pub fn worktree_path(&self, profile_id: &str) -> Result { + self.with_db(|conn| Ok(repo::profile::find_by_id(conn, profile_id)?.worktree_path)) + } + + pub fn set_pinned(&self, project_id: &str, pinned: bool) -> Result<(), AppError> { + let project = self.find_project(project_id)?; + self.update_sidebar_layout(vec![ProjectSidebarLayoutUpdate { + kind: "project".into(), + id: project_id.into(), + group_id: project.group_id, + sort_order: Some(project.sort_order), + pinned_order: if pinned { + Some(project.pinned_order.unwrap_or(0)) + } else { + None + }, + }]) + } + + pub fn list_sessions(&self, project_id: &str) -> Result, AppError> { + self.with_db(|conn| service::pty::list_project_sessions(conn, project_id)) + } + + pub fn take_output(&self, session_id: &str) -> Vec { + self.buffers + .lock() + .ok() + .and_then(|mut map| map.remove(session_id)) + .unwrap_or_default() + } + + pub fn shutdown(&self) { + pty_infra::close_all_sessions(&self.sessions); + pty_infra::join_all_read_threads(&self.read_threads); + service::pty::mark_all_closed(&self.db); + } +} + +pub fn app_data_dir() -> PathBuf { + if let Some(dir) = dirs::data_dir() { + return dir.join("com.akrc.code"); + } + PathBuf::from(".").join(".2code") +} + +pub fn pick_folder() -> Option { + rfd::FileDialog::new() + .set_title("Choose Folder") + .pick_folder() + .map(|p| p.to_string_lossy().into_owned()) +} + +pub fn default_shell() -> String { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()) +} + +#[allow(dead_code)] +pub fn _log_dir(backend: &Backend) -> PtyLogDir { + PtyLogDir(backend.output_dir.clone()) +} + +#[allow(dead_code)] +pub fn _watch_noop() -> Box { + Box::new(NoopWatchSender) +} + +#[allow(dead_code)] +pub fn _channel() -> (mpsc::Sender, mpsc::Receiver) { + mpsc::channel() +} + +pub fn is_previewable(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + [ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".pdf", + ".zip", ".tar", ".gz", ".tgz", ".docx", ".xlsx", ".pptx", + ] + .iter() + .any(|ext| lower.ends_with(ext)) +} + +pub fn is_markdown(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".md") || lower.ends_with(".mdx") +} + +pub fn file_name(path: &str) -> String { + Path::new(path) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()) +} diff --git a/src-gpui/src/i18n.rs b/src-gpui/src/i18n.rs new file mode 100644 index 00000000..c8ad764b --- /dev/null +++ b/src-gpui/src/i18n.rs @@ -0,0 +1,67 @@ +use std::collections::HashMap; +use std::sync::OnceLock; + +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Locale { + #[default] + En, + Zh, +} + +impl Locale { + pub fn as_code(self) -> &'static str { + match self { + Self::En => "en", + Self::Zh => "zh", + } + } + + pub fn from_code(code: &str) -> Self { + if code.starts_with("zh") { + Self::Zh + } else { + Self::En + } + } +} + +fn table(locale: Locale) -> &'static HashMap { + fn parse(raw: &str) -> HashMap { + #[derive(Deserialize)] + struct Loose(HashMap); + let Loose(map) = serde_json::from_str(raw).unwrap_or_else(|_| Loose(HashMap::new())); + map.into_iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string()))) + .collect() + } + + match locale { + Locale::En => { + static EN: OnceLock> = OnceLock::new(); + EN.get_or_init(|| parse(include_str!("../../messages/en.json"))) + } + Locale::Zh => { + static ZH: OnceLock> = OnceLock::new(); + ZH.get_or_init(|| parse(include_str!("../../messages/zh.json"))) + } + } +} + +pub fn t(locale: Locale, key: &str) -> String { + table(locale) + .get(key) + .cloned() + .or_else(|| table(Locale::En).get(key).cloned()) + .unwrap_or_else(|| key.to_string()) +} + +pub fn tf(locale: Locale, key: &str, pairs: &[(&str, &str)]) -> String { + let mut s = t(locale, key); + for (k, v) in pairs { + s = s.replace(&format!("{{{k}}}"), v); + } + s +} diff --git a/src-gpui/src/main.rs b/src-gpui/src/main.rs new file mode 100644 index 00000000..89addfca --- /dev/null +++ b/src-gpui/src/main.rs @@ -0,0 +1,61 @@ +mod app; +mod backend; +mod i18n; +mod prefs; +mod state; +mod ui; + +use gpui::{ + point, prelude::*, px, size, Application, Bounds, TitlebarOptions, WindowBounds, WindowOptions, +}; +use gpui_component::Root; + +use crate::app::AppView; +use crate::backend::Backend; + +fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let backend = match Backend::init() { + Ok(b) => b, + Err(err) => { + eprintln!("failed to init 2code backend: {err}"); + std::process::exit(1); + } + }; + + let app = Application::new(); + app.run(move |cx| { + gpui_component::init(cx); + AppView::bind_keys(cx); + + let bounds = Bounds::centered(None, size(px(1440.), px(900.)), cx); + let backend = backend.clone(); + cx.spawn(async move |cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(960.), px(600.))), + titlebar: Some(TitlebarOptions { + title: Some("2code".into()), + appears_transparent: true, + traffic_light_position: Some(point(px(16.), px(24.))), + }), + ..Default::default() + }, + move |window, cx| { + let view = cx.new(|cx| AppView::new(backend.clone(), window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }, + )?; + Ok::<_, anyhow::Error>(()) + }) + .detach(); + cx.activate(true); + }); +} diff --git a/src-gpui/src/prefs.rs b/src-gpui/src/prefs.rs new file mode 100644 index 00000000..42fd5ae3 --- /dev/null +++ b/src-gpui/src/prefs.rs @@ -0,0 +1,185 @@ +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::i18n::Locale; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ThemePref { + #[default] + System, + Light, + Dark, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RadiusPref { + None, + Small, + #[default] + Medium, + Large, + XLarge, +} + +impl RadiusPref { + pub fn label_key(self) -> &'static str { + match self { + Self::None => "radiusNone", + Self::Small => "radiusSmall", + Self::Medium => "radiusMedium", + Self::Large => "radiusLarge", + Self::XLarge => "radiusXLarge", + } + } + + pub fn scale(self) -> f32 { + match self { + Self::None => 0.0, + Self::Small => 0.6, + Self::Medium => 1.0, + Self::Large => 1.4, + Self::XLarge => 1.8, + } + } + + pub fn all() -> [Self; 5] { + [Self::None, Self::Small, Self::Medium, Self::Large, Self::XLarge] + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TerminalTemplatePref { + pub id: String, + pub name: String, + pub shell: String, + pub cwd: String, + pub commands: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Prefs { + pub language: Locale, + pub theme: ThemePref, + pub radius: RadiusPref, + pub worktree_dir: String, + pub debug_mode: bool, + pub performance_profile: bool, + pub show_avatars: bool, + pub sidebar_width: f32, + pub sidebar_collapsed: bool, + pub collapsed_groups: Vec, + pub profile_sidebar_width: f32, + pub profile_sidebar_open: bool, + pub terminal_theme_dark: String, + pub terminal_theme_light: String, + pub sync_terminal_theme: bool, + pub font_family: String, + pub font_size: f32, + pub default_shell: String, + pub custom_shell: String, + pub notifications: bool, + pub notification_sound: String, + pub topbar_controls: Vec, + pub editor_app: String, + pub terminal_app: String, + pub accept_beta: bool, + pub templates: Vec, + pub collapsed_projects: Vec, +} + +impl Default for Prefs { + fn default() -> Self { + Self { + language: Locale::En, + theme: ThemePref::System, + radius: RadiusPref::Medium, + worktree_dir: String::new(), + debug_mode: false, + performance_profile: false, + show_avatars: true, + sidebar_width: 250.0, + sidebar_collapsed: false, + collapsed_groups: Vec::new(), + profile_sidebar_width: 208.0, + profile_sidebar_open: true, + terminal_theme_dark: "GitHub Dark".into(), + terminal_theme_light: "GitHub Light".into(), + sync_terminal_theme: false, + font_family: "JetBrains Mono".into(), + font_size: 13.0, + default_shell: String::new(), + custom_shell: String::new(), + notifications: true, + notification_sound: String::new(), + topbar_controls: vec![ + "github-desktop".into(), + "editor".into(), + "terminal".into(), + "pr-status".into(), + ], + editor_app: "code".into(), + terminal_app: String::new(), + accept_beta: false, + templates: Vec::new(), + collapsed_projects: Vec::new(), + } + } +} + +impl Prefs { + pub fn load(app_data_dir: &Path) -> Self { + let path = app_data_dir.join("gpui-prefs.json"); + std::fs::read_to_string(path) + .ok() + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default() + } + + pub fn save(&self, app_data_dir: &Path) { + let path = app_data_dir.join("gpui-prefs.json"); + if let Ok(raw) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, raw); + } + } + + pub fn effective_shell(&self) -> String { + if self.default_shell == "custom" && !self.custom_shell.is_empty() { + return self.custom_shell.clone(); + } + if !self.default_shell.is_empty() && self.default_shell != "custom" { + return self.default_shell.clone(); + } + crate::backend::default_shell() + } +} + +#[derive(Debug, Clone, Copy)] +pub struct TermTheme { + pub name: &'static str, + pub bg: u32, + pub fg: u32, + pub cursor: u32, + pub is_light: bool, +} + +pub const TERM_THEMES: &[TermTheme] = &[ + TermTheme { name: "GitHub Dark", bg: 0x0d1117, fg: 0xc9d1d9, cursor: 0x58a6ff, is_light: false }, + TermTheme { name: "GitHub Light", bg: 0xffffff, fg: 0x1f2328, cursor: 0x0969da, is_light: true }, + TermTheme { name: "Dracula", bg: 0x282a36, fg: 0xf8f8f2, cursor: 0xff79c6, is_light: false }, + TermTheme { name: "Ayu Dark", bg: 0x0a0e14, fg: 0xb3b1ad, cursor: 0xffb454, is_light: false }, + TermTheme { name: "Ayu Light", bg: 0xfafafa, fg: 0x5c6773, cursor: 0xff6a00, is_light: true }, + TermTheme { name: "Solarized Dark", bg: 0x002b36, fg: 0x839496, cursor: 0x268bd2, is_light: false }, + TermTheme { name: "Solarized Light", bg: 0xfdf6e3, fg: 0x657b83, cursor: 0x268bd2, is_light: true }, + TermTheme { name: "One Dark", bg: 0x282c34, fg: 0xabb2bf, cursor: 0x61afef, is_light: false }, + TermTheme { name: "One Light", bg: 0xfafafa, fg: 0x383a42, cursor: 0x4078f2, is_light: true }, +]; + +pub fn term_theme_by_name(name: &str) -> &'static TermTheme { + TERM_THEMES + .iter() + .find(|t| t.name == name) + .unwrap_or(&TERM_THEMES[0]) +} diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs new file mode 100644 index 00000000..d635cb98 --- /dev/null +++ b/src-gpui/src/state.rs @@ -0,0 +1,343 @@ +use std::collections::{HashMap, HashSet}; +use std::time::Instant; + +use model::filesystem::FileSearchResult; +use model::profile::Profile; +use model::project::{GitBranchInfo, GitCommit, GitDiffStats, GitPullRequestStatus, ProjectConfig, ProjectWithProfiles}; +use model::project_group::ProjectGroup; + +use crate::i18n::Locale; +use crate::prefs::Prefs; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Route { + #[default] + Home, + Workspace, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SidebarMode { + #[default] + Files, + Git, + Notes, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SettingsTab { + #[default] + General, + Terminal, + Templates, + Notification, + TopBar, + About, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GitDiffTab { + #[default] + Changes, + History, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DiffPreviewMode { + #[default] + Unified, + Split, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AgentStatus { + #[default] + Idle, + Running, + Waiting, + Completed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DialogKind { + CreateProject, + DeleteProject, + RenameProject, + ProjectSettings, + CreateProfile, + DeleteProfile, + CloseUnsaved, + SwitchBranch, + OpenLink, + ChooseFile, + EditTemplate, + ReviewQueue, + DebugLog, + CreateGroup, +} + +#[derive(Debug, Clone)] +pub enum ContextMenu { + Project { id: String }, + Profile { id: String, project_id: String }, + File { path: String }, + TreeBlank, + NewTerminal, +} + +#[derive(Debug, Clone)] +pub struct Toast { + pub id: u64, + pub kind: ToastKind, + pub title: String, + pub body: String, + pub action: Option, + pub created: Instant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + Success, + Info, + Warning, + Error, +} + +#[derive(Debug, Clone)] +pub struct OpenFileTab { + pub path: String, + pub title: String, + pub content: String, + pub draft: String, + pub preview: bool, + pub preview_kind: String, + pub binary_note: String, +} + +impl OpenFileTab { + pub fn dirty(&self) -> bool { + !self.preview && self.draft != self.content + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnifiedTab { + Terminal { index: usize }, + File { index: usize }, +} + +pub struct TermSession { + pub id: String, + pub title: String, + pub profile_id: String, + pub parser: vt100::Parser, + pub search_open: bool, + pub search_ix: usize, + pub agent: AgentStatus, + pub completed_hidden: bool, + pub cols: u16, + pub rows: u16, +} + +impl TermSession { + pub fn new(id: String, title: String, profile_id: String) -> Self { + Self { + id, + title, + profile_id, + parser: vt100::Parser::new(32, 120, 10_000), + search_open: false, + search_ix: 0, + agent: AgentStatus::Idle, + completed_hidden: false, + cols: 120, + rows: 32, + } + } + + pub fn feed(&mut self, bytes: &[u8]) { + self.parser.process(bytes); + self.detect_agent(); + } + + fn detect_agent(&mut self) { + let text = self.parser.screen().contents(); + let lower = text.to_ascii_lowercase(); + if lower.contains("waiting for") || lower.contains("waiting on") { + self.agent = AgentStatus::Waiting; + } else if lower.contains("running") || lower.contains("thinking") { + self.agent = AgentStatus::Running; + } else if matches!(self.agent, AgentStatus::Running | AgentStatus::Waiting) { + self.agent = AgentStatus::Completed; + self.completed_hidden = false; + } + } + + pub fn screen_text(&self) -> String { + self.parser.screen().contents() + } +} + +#[derive(Debug, Clone)] +pub struct TreeNode { + pub path: String, + pub name: String, + pub is_dir: bool, + pub expanded: bool, + pub children_loaded: bool, + pub children: Vec, +} + +pub struct Workspace { + pub project_id: String, + pub profile_id: String, + pub branch: String, + pub worktree: String, + pub project_name: String, + pub is_default: bool, + pub sidebar_mode: SidebarMode, + pub sidebar_open: bool, + pub terminals: Vec, + pub files: Vec, + pub active: Option, + pub tree: HashMap, + pub tree_error: Option, + pub git_files: Vec<(String, String)>, + pub git_included: HashSet, + pub git_stats: GitDiffStats, + pub git_ahead: u32, + pub notes: String, + pub notes_status: NotesStatus, + pub pr: Option, + pub avatar: Option, + pub config: ProjectConfig, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NotesStatus { + #[default] + Saved, + Saving, + Failed, +} + +impl Workspace { + pub fn active_terminal_mut(&mut self) -> Option<&mut TermSession> { + match self.active { + Some(UnifiedTab::Terminal { index }) => self.terminals.get_mut(index), + _ => None, + } + } + + pub fn active_file_mut(&mut self) -> Option<&mut OpenFileTab> { + match self.active { + Some(UnifiedTab::File { index }) => self.files.get_mut(index), + _ => None, + } + } + + pub fn has_tabs(&self) -> bool { + !self.terminals.is_empty() || !self.files.is_empty() + } +} + +#[derive(Default)] +pub struct OverlayState { + pub dialog: Option, + pub dialog_project: Option, + pub dialog_profile: Option, + pub dialog_file: Option, + pub dialog_url: Option, + pub dialog_folder: Option, + pub dialog_error: Option, + pub dialog_busy: bool, + pub delete_warning: Option, + pub context_menu: Option<(ContextMenu, f32, f32)>, + pub palette_open: bool, + pub palette_results: Vec, + pub palette_index: usize, + pub git_diff_open: bool, + pub git_diff_tab: GitDiffTab, + pub git_diff_mode: DiffPreviewMode, + pub git_diff_text: String, + pub git_diff_file: Option, + pub git_commits: Vec, + pub git_commit_files: Vec, + pub git_selected_commit: Option, + pub git_large_revealed: HashSet, + pub review_comments: Vec, + pub branches: Vec, + pub fuzzy_files: Vec, + pub onboarding: bool, + pub sort_mode: bool, + pub settings_open: bool, + pub settings_tab: SettingsTab, + pub debug_open: bool, + pub debug_logs: Vec, + pub expanded_projects: HashSet, + pub pending_close_file: Option, + pub editing_template: Option, + pub project_settings_tab: usize, +} + +pub struct AppData { + pub prefs: Prefs, + pub projects: Vec, + pub groups: Vec, + pub route: Route, + pub current_project: Option, + pub current_profile: Option, + pub workspaces: HashMap, + pub overlay: OverlayState, + pub toasts: Vec, + pub toast_seq: u64, + pub sidebar_error: Option, + pub locale: Locale, +} + +impl AppData { + pub fn current_ws(&self) -> Option<&Workspace> { + self.current_profile + .as_ref() + .and_then(|id| self.workspaces.get(id)) + } + + pub fn current_ws_mut(&mut self) -> Option<&mut Workspace> { + let id = self.current_profile.clone()?; + self.workspaces.get_mut(&id) + } + + pub fn default_profile_of(&self, project_id: &str) -> Option { + self.projects + .iter() + .find(|p| p.id == project_id) + .and_then(|p| { + p.profiles + .iter() + .find(|pr| pr.is_default) + .cloned() + .or_else(|| p.profiles.first().cloned()) + }) + } + + pub fn project(&self, id: &str) -> Option<&ProjectWithProfiles> { + self.projects.iter().find(|p| p.id == id) + } + + pub fn push_toast(&mut self, kind: ToastKind, title: impl Into, body: impl Into) { + self.toast_seq += 1; + self.toasts.push(Toast { + id: self.toast_seq, + kind, + title: title.into(), + body: body.into(), + action: None, + created: Instant::now(), + }); + } + + pub fn expire_toasts(&mut self) { + self.toasts + .retain(|t| t.created.elapsed().as_secs() < if t.action.is_some() { 12 } else { 5 }); + } +} diff --git a/src-gpui/src/ui/debug.rs b/src-gpui/src/ui/debug.rs new file mode 100644 index 00000000..77a2cb75 --- /dev/null +++ b/src-gpui/src/ui/debug.rs @@ -0,0 +1,106 @@ +use gpui::{div, prelude::*, px, Context, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; + +use crate::app::AppView; + +pub fn render_fab(app: &AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !app.data.prefs.debug_mode { + return div().id("debug-fab-off").into_any_element(); + } + let view = cx.entity(); + div() + .id("debug-fab") + .absolute() + .right(px(64.)) + .bottom(px(16.)) + .child( + Button::new("debug-toggle") + .icon(IconName::Inspector) + .tooltip(app.t("debugLog")) + .on_click(move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.debug_open = !app.data.overlay.debug_open; + cx.notify(); + }); + }), + ) + .into_any_element() +} + +pub fn render_panel(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !app.data.overlay.debug_open && app.data.overlay.dialog != Some(crate::state::DialogKind::DebugLog) { + return div().id("debug-panel-off").into_any_element(); + } + if !app.data.overlay.debug_open { + return debug_body(app, cx).into_any_element(); + } + let theme = cx.theme().clone(); + div() + .id("debug-panel") + .absolute() + .right(px(16.)) + .bottom(px(64.)) + .w(px(420.)) + .h(px(280.)) + .rounded_lg() + .bg(theme.background) + .border_1() + .border_color(theme.border) + .shadow_lg() + .child(debug_body(app, cx)) + .into_any_element() +} + +fn debug_body(app: &mut AppView, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let q = app.inputs.debug_search.read(cx).value().to_string(); + let logs: Vec<_> = app + .data + .overlay + .debug_logs + .iter() + .filter(|l| q.is_empty() || l.to_ascii_lowercase().contains(&q.to_ascii_lowercase())) + .cloned() + .collect(); + v_flex() + .size_full() + .child( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_2() + .child(div().flex_1().child(Input::new(&app.inputs.debug_search))) + .child( + Button::new("debug-clear") + .xsmall() + .label(app.t("debugClear")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.debug_logs.clear(); + cx.notify(); + }); + } + }), + ), + ) + .child(if logs.is_empty() { + div() + .flex_1() + .p_4() + .child(app.t("debugNoLogs")) + .into_any_element() + } else { + v_flex() + .flex_1() + .p_2() + .children(logs.into_iter().map(|l| { + div().text_xs().font_family("monospace").child(l) + })) + .into_any_element() + }) +} diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs new file mode 100644 index 00000000..d2acc0af --- /dev/null +++ b/src-gpui/src/ui/dialogs.rs @@ -0,0 +1,700 @@ +use gpui::{div, prelude::*, px, Context, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{Disableable, Selectable}; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; + +use crate::app::AppView; +use crate::backend; +use crate::state::{ContextMenu, DialogKind}; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .id("overlays") + .absolute() + .inset_0() + .child(context_menu(app, cx)) + .child(dialog(app, window, cx)) +} + +fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(kind) = app.data.overlay.dialog else { + return div().id("no-dialog").into_any_element(); + }; + let theme = cx.theme().clone(); + let view = cx.entity(); + let title = match kind { + DialogKind::CreateProject => app.t("createProject"), + DialogKind::DeleteProject => app.t("deleteProject"), + DialogKind::RenameProject => app.t("rename"), + DialogKind::ProjectSettings => app.t("projectSettings"), + DialogKind::CreateProfile => app.t("createProfile"), + DialogKind::DeleteProfile => app.t("deleteProfile"), + DialogKind::CloseUnsaved => app.t("closeUnsavedFileTitle"), + DialogKind::SwitchBranch => app.t("switchBranchTitle"), + DialogKind::OpenLink => app.t("terminalOpenLink"), + DialogKind::ChooseFile => app.t("terminalChooseFilePath"), + DialogKind::EditTemplate => app.t("editTerminalTemplate"), + DialogKind::ReviewQueue => app.t("reviewQueue"), + DialogKind::DebugLog => app.t("debugLog"), + DialogKind::CreateGroup => app.t("createProjectGroup"), + }; + + div() + .id("dialog-mask") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::hsla(0., 0., 0., 0.12)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("dialog-panel") + .w(px(if matches!(kind, DialogKind::ProjectSettings | DialogKind::SwitchBranch | DialogKind::DebugLog) { + 560. + } else { + 380. + })) + .max_h(px(640.)) + .p_4() + .gap_3() + .rounded_xl() + .bg(theme.background) + .border_1() + .border_color(theme.border) + .shadow_lg() + .on_click(|_, _, _| {}) + .child( + h_flex() + .justify_between() + .child(div().font_semibold().child(title)) + .child( + Button::new("dlg-x") + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + cx.notify(); + }); + } + }), + ), + ) + .child(dialog_body(app, kind, window, cx)) + .child(dialog_footer(app, kind, cx)), + ) + .into_any_element() +} + +fn dialog_body( + app: &mut AppView, + kind: DialogKind, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + match kind { + DialogKind::CreateProject => { + let folder = app.data.overlay.dialog_folder.clone(); + let hint = if folder.is_none() { + app.t("createProjectChooseFolderHint") + } else if app.inputs.project_name.read(cx).value().is_empty() { + app.t("createProjectHintFolderEmpty") + } else { + app.t("createProjectHintFolderNamed") + }; + v_flex() + .gap_3() + .child(if let Some(folder) = folder.clone() { + v_flex() + .gap_1() + .child( + h_flex() + .justify_between() + .child(div().text_sm().child(app.t("folder"))) + .child( + Button::new("rechoose") + .xsmall() + .label(app.t("chooseFolder")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(p) = backend::pick_folder() { + app.data.overlay.dialog_folder = Some(p); + } + cx.notify(); + }); + } + }), + ), + ) + .child( + div() + .p_2() + .rounded_md() + .bg(theme.muted) + .font_family("monospace") + .text_xs() + .child(folder), + ) + .into_any_element() + } else { + div() + .id("choose-folder-big") + .w_full() + .h(px(88.)) + .rounded_lg() + .border_1() + .border_color(theme.border) + .flex() + .flex_col() + .items_center() + .justify_center() + .gap_2() + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(p) = backend::pick_folder() { + app.data.overlay.dialog_folder = Some(p); + } + cx.notify(); + }); + } + }) + .child(Icon::new(IconName::Folder).w(px(24.))) + .child(div().text_sm().child(app.t("chooseFolder"))) + .into_any_element() + }) + .child(div().text_sm().child(app.t("projectName"))) + .child(Input::new(&app.inputs.project_name)) + .child(div().text_xs().text_color(theme.muted_foreground).child(hint)) + .into_any_element() + } + DialogKind::DeleteProject => div() + .text_sm() + .child(app.t("confirmDeleteProject")) + .into_any_element(), + DialogKind::RenameProject => v_flex() + .gap_2() + .child(div().text_sm().child(app.t("newName"))) + .child(Input::new(&app.inputs.rename)) + .into_any_element(), + DialogKind::ProjectSettings => v_flex() + .gap_2() + .child( + h_flex() + .gap_2() + .child( + Button::new("ps-scripts") + .small() + .selected(app.data.overlay.project_settings_tab == 0) + .icon(IconName::ALargeSmall) + .label(app.t("scripts")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.project_settings_tab = 0; + cx.notify(); + }); + } + }), + ) + .child( + Button::new("ps-templates") + .small() + .selected(app.data.overlay.project_settings_tab == 1) + .icon(IconName::SquareTerminal) + .label(app.t("templates")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.project_settings_tab = 1; + cx.notify(); + }); + } + }), + ), + ) + .child(if app.data.overlay.project_settings_tab == 0 { + v_flex() + .gap_2() + .child(div().text_sm().child(app.t("projectWorktreeDir"))) + .child(div().text_xs().text_color(theme.muted_foreground).child(app.t("projectWorktreeDirDesc"))) + .child(Input::new(&app.inputs.worktree)) + .child(div().text_sm().child(app.t("initScript"))) + .child(Input::new(&app.inputs.init_script)) + .child(div().text_sm().child(app.t("setupScript"))) + .child(Input::new(&app.inputs.setup_script)) + .child(div().text_sm().child(app.t("teardownScript"))) + .child(Input::new(&app.inputs.teardown_script)) + .into_any_element() + } else { + v_flex() + .gap_2() + .child(div().text_sm().child(app.t("projectTerminalTemplates"))) + .child(div().text_xs().text_color(theme.muted_foreground).child(app.t("projectTerminalTemplatesDescription"))) + .into_any_element() + }) + .into_any_element(), + DialogKind::CreateProfile => v_flex() + .gap_2() + .child(div().text_sm().child(app.t("branchName"))) + .child(Input::new(&app.inputs.profile_branch)) + .into_any_element(), + DialogKind::DeleteProfile => v_flex() + .gap_2() + .child(div().text_sm().child(app.t("confirmDeleteProfile"))) + .when(app.data.overlay.dialog_busy, |el| { + el.child(div().text_xs().child(app.t("deleteProfileCheckingGitStatus"))) + }) + .when_some(app.data.overlay.delete_warning.clone(), |el, warn| { + el.child( + div() + .p_2() + .rounded_md() + .bg(theme.warning) + .child(div().font_semibold().child(app.t("deleteProfileGitWarningTitle"))) + .child(div().text_sm().child(warn)), + ) + }) + .into_any_element(), + DialogKind::CloseUnsaved => div().text_sm().child(crate::i18n::tf( + app.data.locale, + "closeUnsavedFileDescription", + &[("file", app.data.overlay.dialog_file.as_deref().unwrap_or(""))], + )).into_any_element(), + DialogKind::SwitchBranch => { + let q = app.inputs.branch_search.read(cx).value().to_string(); + let branches: Vec<_> = app + .data + .overlay + .branches + .iter() + .filter(|b| q.is_empty() || b.name.to_ascii_lowercase().contains(&q.to_ascii_lowercase())) + .cloned() + .collect(); + v_flex() + .gap_2() + .child(Input::new(&app.inputs.branch_search)) + .child(if branches.is_empty() { + div().p_3().child(app.t("noBranchesFound")).into_any_element() + } else { + v_flex() + .max_h(px(320.)) + .children(branches.into_iter().map(|b| { + let name = b.name.clone(); + let disabled = b.is_current || b.is_used; + h_flex() + .id(crate::ui::eid(format!("br-{name}"))) + .px_2() + .py_1() + .gap_2() + .rounded_md() + .when(b.is_current, |el| el.bg(theme.muted)) + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let name = name.clone(); + move |_, _, cx| { + if disabled { + return; + } + view.update(cx, |app, cx| { + app.checkout_branch(&name); + cx.notify(); + }); + } + }) + .child(div().flex_1().child(name)) + .when(b.is_current, |el| el.child(badge(&app.t("branchCurrentLabel"), theme.muted_foreground))) + .when(b.is_trunk, |el| el.child(badge(&app.t("branchTrunkLabel"), theme.muted_foreground))) + .when(b.is_used, |el| el.child(badge(&app.t("branchUsedLabel"), theme.warning))) + .when(b.ahead > 0, |el| el.child(div().text_xs().child(format!("↑{}", b.ahead)))) + .when(b.behind > 0, |el| el.child(div().text_xs().child(format!("↓{}", b.behind)))) + })) + .into_any_element() + }) + .into_any_element() + } + DialogKind::OpenLink => v_flex() + .gap_2() + .child(div().text_sm().child(app.t("terminalOpenLinkConfirmDescription"))) + .child(div().font_family("monospace").text_xs().child(app.data.overlay.dialog_url.clone().unwrap_or_default())) + .into_any_element(), + DialogKind::ChooseFile => v_flex() + .gap_2() + .child(div().text_sm().child(app.t("terminalChooseFilePathDescription"))) + .children(app.data.overlay.fuzzy_files.iter().map(|f| { + let path = f.path.clone(); + div() + .id(crate::ui::eid(format!("fuzzy-{path}"))) + .px_2() + .py_1() + .on_click({ + let view = view.clone(); + let path = path.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + if let Some(pid) = app.data.current_profile.clone() { + app.open_file(&pid, &path, window, cx); + } + app.data.overlay.dialog = None; + cx.notify(); + }); + } + }) + .child(f.name.clone()) + })) + .into_any_element(), + DialogKind::EditTemplate => v_flex() + .gap_2() + .child(Input::new(&app.inputs.template_name)) + .child(Input::new(&app.inputs.template_shell)) + .child(Input::new(&app.inputs.template_cwd)) + .child(Input::new(&app.inputs.template_commands)) + .into_any_element(), + DialogKind::ReviewQueue => v_flex() + .gap_2() + .children(app.data.overlay.review_comments.iter().map(|c| div().child(c.clone()))) + .when(app.data.overlay.review_comments.is_empty(), |el| { + el.child(div().text_color(theme.muted_foreground).child("—")) + }) + .into_any_element(), + DialogKind::DebugLog => crate::ui::debug::render_panel(app, _window, cx).into_any_element(), + DialogKind::CreateGroup => v_flex() + .gap_2() + .child(div().text_sm().child(app.t("projectGroupName"))) + .child(Input::new(&app.inputs.group_name)) + .into_any_element(), + } +} + +fn badge(text: &str, color: gpui::Hsla) -> impl IntoElement { + div() + .px_1() + .text_xs() + .rounded_md() + .bg(color) + .child(text.to_string()) +} + +fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let danger = matches!( + kind, + DialogKind::DeleteProject | DialogKind::DeleteProfile | DialogKind::CloseUnsaved + ); + let ok = match kind { + DialogKind::CreateProject => app.t("create"), + DialogKind::DeleteProject => app.t("delete"), + DialogKind::RenameProject => app.t("rename"), + DialogKind::ProjectSettings => app.t("save"), + DialogKind::CreateProfile => app.t("create"), + DialogKind::DeleteProfile => { + if app.data.overlay.delete_warning.is_some() { + app.t("deleteProfileAnyway") + } else { + app.t("delete") + } + } + DialogKind::CloseUnsaved => app.t("discardChanges"), + DialogKind::OpenLink => app.t("browserOpenDefault"), + DialogKind::CreateGroup => app.t("create"), + DialogKind::ReviewQueue => app.t("reviewCommentsCopied"), + _ => app.t("cancel"), + }; + let show_ok = !matches!(kind, DialogKind::SwitchBranch | DialogKind::DebugLog | DialogKind::ChooseFile); + + h_flex() + .justify_end() + .gap_2() + .child( + Button::new("dlg-cancel") + .small() + .label(app.t("cancel")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + cx.notify(); + }); + } + }), + ) + .when(show_ok, |el| { + el.child( + Button::new("dlg-ok") + .small() + .when(danger, |b| b.danger()) + .when(!danger, |b| b.primary()) + .label(ok) + .disabled(matches!(kind, DialogKind::CreateProject) && app.data.overlay.dialog_folder.is_none()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + match kind { + DialogKind::CreateProject => app.create_project_from_dialog(window, cx), + DialogKind::DeleteProject => app.delete_current_dialog_project(), + DialogKind::RenameProject => app.rename_dialog_project(cx), + DialogKind::ProjectSettings => app.save_project_settings(cx), + DialogKind::CreateProfile => app.create_profile_from_dialog(cx), + DialogKind::DeleteProfile => app.delete_dialog_profile(), + DialogKind::CloseUnsaved => { + if let (Some(pid), Some(path)) = ( + app.data.current_profile.clone(), + app.data.overlay.pending_close_file.clone(), + ) { + app.close_file(&pid, &path); + } + app.data.overlay.dialog = None; + } + DialogKind::OpenLink => { + if let Some(url) = app.data.overlay.dialog_url.clone() { + let _ = open::that(url); + } + app.data.overlay.dialog = None; + } + DialogKind::CreateGroup => { + let name = app.inputs.group_name.read(cx).value().to_string(); + if let Ok(group) = app.backend.create_group(&name) { + if let Some(pid) = app.data.overlay.dialog_project.clone() { + let _ = app.backend.assign_to_group(&pid, Some(group.id)); + } + app.reload_projects(); + } + app.data.overlay.dialog = None; + } + DialogKind::ReviewQueue => { + let text = app.data.overlay.review_comments.join("\n"); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); + app.data.push_toast( + crate::state::ToastKind::Success, + app.t("reviewCommentsCopied"), + "", + ); + } + _ => app.data.overlay.dialog = None, + } + cx.notify(); + }); + } + }), + ) + }) +} + +fn context_menu(app: &AppView, cx: &mut Context) -> impl IntoElement { + let Some((menu, x, y)) = app.data.overlay.context_menu.clone() else { + return div().id("no-ctx").into_any_element(); + }; + let theme = cx.theme().clone(); + let view = cx.entity(); + let items = menu_items(app, &menu); + + div() + .id("ctx-mask") + .absolute() + .inset_0() + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.context_menu = None; + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("ctx-menu") + .absolute() + .left(px(x)) + .top(px(y)) + .min_w(px(220.)) + .py_1() + .rounded_lg() + .bg(theme.background) + .border_1() + .border_color(theme.border) + .shadow_md() + .children(items.into_iter().map(|(label, danger, action)| { + div() + .id(crate::ui::eid(format!("ctx-{label}"))) + .px_3() + .py_1() + .text_sm() + .when(danger, |el| el.text_color(theme.danger)) + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let menu = menu.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + run_menu(app, &menu, action, window, cx); + app.data.overlay.context_menu = None; + cx.notify(); + }); + } + }) + .child(label) + })), + ) + .into_any_element() +} + +#[derive(Clone, Copy)] +enum MenuAction { + AddToGroup, + CreateGroup, + RemoveGroup, + ProjectSettings, + Rename, + DeleteProject, + DeleteProfile, + Open, + OpenDefault, + Reveal, + Refresh, + NewFile, + NewFolder, + RenamePath, + CopyRel, + CopyAbs, + DeletePath, + Template(usize), + NewTerm, +} + +fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec<(String, bool, MenuAction)> { + match menu { + ContextMenu::Project { .. } => vec![ + (app.t("addToProjectGroup"), false, MenuAction::AddToGroup), + (app.t("createProjectGroup"), false, MenuAction::CreateGroup), + (app.t("removeFromProjectGroup"), false, MenuAction::RemoveGroup), + (app.t("projectSettings"), false, MenuAction::ProjectSettings), + (app.t("renameProject"), false, MenuAction::Rename), + (app.t("deleteProject"), true, MenuAction::DeleteProject), + ], + ContextMenu::Profile { .. } => vec![(app.t("deleteProfile"), true, MenuAction::DeleteProfile)], + ContextMenu::File { .. } => vec![ + (app.t("fileTreeContextMenuOpen"), false, MenuAction::Open), + (app.t("fileTreeContextMenuOpenInDefaultApp"), false, MenuAction::OpenDefault), + (app.t("fileTreeContextMenuRevealInFileManager"), false, MenuAction::Reveal), + (app.t("fileTreeContextMenuRefresh"), false, MenuAction::Refresh), + (app.t("fileTreeContextMenuNewFile"), false, MenuAction::NewFile), + (app.t("fileTreeContextMenuNewFolder"), false, MenuAction::NewFolder), + (app.t("rename"), false, MenuAction::RenamePath), + (app.t("fileTreeContextMenuCopyRelativePath"), false, MenuAction::CopyRel), + (app.t("fileTreeContextMenuCopyAbsolutePath"), false, MenuAction::CopyAbs), + (app.t("delete"), true, MenuAction::DeletePath), + ], + ContextMenu::TreeBlank => vec![ + (app.t("fileTreeContextMenuNewFile"), false, MenuAction::NewFile), + (app.t("fileTreeContextMenuNewFolder"), false, MenuAction::NewFolder), + (app.t("fileTreeContextMenuRefresh"), false, MenuAction::Refresh), + (app.t("fileTreeContextMenuRevealInFileManager"), false, MenuAction::Reveal), + ], + ContextMenu::NewTerminal => { + let mut items = vec![(app.t("newTerminal"), false, MenuAction::NewTerm)]; + for (i, t) in app.data.prefs.templates.iter().enumerate() { + items.push((t.name.clone(), false, MenuAction::Template(i))); + } + items + } + } +} + +fn run_menu( + app: &mut AppView, + menu: &ContextMenu, + action: MenuAction, + window: &mut Window, + cx: &mut Context, +) { + match (menu, action) { + (ContextMenu::Project { id }, MenuAction::DeleteProject) => { + app.data.overlay.dialog = Some(DialogKind::DeleteProject); + app.data.overlay.dialog_project = Some(id.clone()); + } + (ContextMenu::Project { id }, MenuAction::Rename) => { + app.data.overlay.dialog = Some(DialogKind::RenameProject); + app.data.overlay.dialog_project = Some(id.clone()); + if let Some(p) = app.data.project(id) { + app.inputs.rename.update(cx, |s, cx| { + s.set_value(p.name.clone(), window, cx); + }); + } + } + (ContextMenu::Project { id }, MenuAction::ProjectSettings) => { + app.data.overlay.dialog = Some(DialogKind::ProjectSettings); + app.data.overlay.dialog_project = Some(id.clone()); + } + (ContextMenu::Project { id }, MenuAction::CreateGroup) => { + app.data.overlay.dialog = Some(DialogKind::CreateGroup); + app.data.overlay.dialog_project = Some(id.clone()); + } + (ContextMenu::Project { id }, MenuAction::RemoveGroup) => { + let _ = app.backend.assign_to_group(id, None); + app.reload_projects(); + } + (ContextMenu::Project { id }, MenuAction::AddToGroup) => { + if let Some(g) = app.data.groups.first() { + let _ = app.backend.assign_to_group(id, Some(g.id.clone())); + app.reload_projects(); + } else { + app.data.overlay.dialog = Some(DialogKind::CreateGroup); + app.data.overlay.dialog_project = Some(id.clone()); + } + } + (ContextMenu::Profile { id, .. }, MenuAction::DeleteProfile) => { + app.prepare_delete_profile(id); + } + (ContextMenu::File { path }, MenuAction::Open) => { + if let Some(pid) = app.data.current_profile.clone() { + app.open_file(&pid, path, window, cx); + } + } + (ContextMenu::File { path }, MenuAction::OpenDefault) => app.open_external(path), + (ContextMenu::File { path }, MenuAction::Reveal) => app.reveal(Some(path)), + (_, MenuAction::Reveal) => app.reveal(None), + (_, MenuAction::Refresh) => { + if let Some(pid) = app.data.current_profile.clone() { + app.load_tree_root(&pid); + } + } + (_, MenuAction::NewFile) => app.create_path(false, cx), + (_, MenuAction::NewFolder) => app.create_path(true, cx), + (ContextMenu::File { path }, MenuAction::CopyRel) => app.copy_path(path, false, cx), + (ContextMenu::File { path }, MenuAction::CopyAbs) => app.copy_path(path, true, cx), + (ContextMenu::File { path }, MenuAction::DeletePath) => app.delete_tree_path(path), + (_, MenuAction::NewTerm) => app.create_terminal(&app.t("newTerminal"), "", Vec::new()), + (_, MenuAction::Template(i)) => { + if let Some(t) = app.data.prefs.templates.get(i).cloned() { + app.create_terminal(&t.name, &t.cwd, t.commands); + } + } + _ => {} + } +} diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs new file mode 100644 index 00000000..0cc18d19 --- /dev/null +++ b/src-gpui/src/ui/file_tree.rs @@ -0,0 +1,157 @@ +use gpui::{div, prelude::*, px, Context, MouseButton, Window}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, StyledExt}; + +use crate::app::AppView; +use crate::state::ContextMenu; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(ws) = app.data.current_ws() else { + return div().id("file-tree-empty").into_any_element(); + }; + let profile = ws.profile_id.clone(); + let root = ws.tree.get("").cloned(); + let err = ws.tree_error.clone(); + + v_flex() + .id("file-tree") + .size_full() + .px_1() + .py_1() + .text_sm() + .on_mouse_down(MouseButton::Right, { + let view = view.clone(); + move |ev, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.context_menu = Some(( + ContextMenu::TreeBlank, + f32::from(ev.position.x), + f32::from(ev.position.y), + )); + cx.notify(); + }); + } + }) + .when_some(err, |el, err| { + el.child( + div() + .text_xs() + .text_color(theme.danger) + .opacity(0.8) + .child(err), + ) + }) + .child(match root { + None => div() + .p_3() + .text_color(theme.muted_foreground) + .child(app.t("fileTreeLoading")) + .into_any_element(), + Some(root) if root.children.is_empty() => div() + .p_3() + .text_color(theme.muted_foreground) + .child(app.t("fileTreeEmptyDirectory")) + .into_any_element(), + Some(root) => v_flex() + .children(root.children.iter().map(|path| { + node_view(app, path, 0, &profile, window, cx) + })) + .into_any_element(), + }) + .into_any_element() +} + +fn node_view( + app: &AppView, + path: &str, + depth: usize, + profile: &str, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(node) = app.data.current_ws().and_then(|w| w.tree.get(path)).cloned() else { + return div().into_any_element(); + }; + let status = app + .data + .current_ws() + .and_then(|w| w.git_files.iter().find(|(p, _)| p == path || p.ends_with(path))) + .map(|(_, s)| s.clone()); + let indent = px(12. * depth as f32 + 4.); + let path_owned = path.to_string(); + let profile_owned = profile.to_string(); + + v_flex() + .child( + h_flex() + .id(crate::ui::eid(format!("tree-{path}"))) + .w_full() + .pl(indent) + .pr_1() + .py_0p5() + .rounded_md() + .gap_1() + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let path = path_owned.clone(); + let profile = profile_owned.clone(); + let is_dir = node.is_dir; + move |_, window, cx| { + view.update(cx, |app, cx| { + if is_dir { + app.toggle_dir(&profile, &path); + } else { + app.open_file(&profile, &path, window, cx); + } + cx.notify(); + }); + } + }) + .on_mouse_down(MouseButton::Right, { + let view = view.clone(); + let path = path_owned.clone(); + move |ev, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.context_menu = Some(( + ContextMenu::File { path: path.clone() }, + f32::from(ev.position.x), + f32::from(ev.position.y), + )); + cx.notify(); + }); + } + }) + .child(Icon::new(if node.is_dir { + if node.expanded { + IconName::FolderOpen + } else { + IconName::Folder + } + } else { + IconName::File + }).w(px(13.))) + .child(div().flex_1().child(node.name.clone())) + .when_some(status, |el, st| { + el.child( + div() + .text_xs() + .text_color(if st.contains('D') { + theme.danger + } else { + theme.success + }) + .child(crate::app::file_status_badge(&st)), + ) + }), + ) + .when(node.is_dir && node.expanded, |el| { + el.children(node.children.iter().map(|child| { + node_view(app, child, depth + 1, profile, window, cx) + })) + }) + .into_any_element() +} diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs new file mode 100644 index 00000000..fd6ff9d8 --- /dev/null +++ b/src-gpui/src/ui/file_viewer.rs @@ -0,0 +1,167 @@ +use gpui::{div, prelude::*, px, Context, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::Input; +use gpui_component::text::TextView; +use gpui_component::{h_flex, v_flex, ActiveTheme, Sizable, StyledExt}; + +use crate::app::AppView; +use crate::backend; +use crate::state::UnifiedTab; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(ws) = app.data.current_ws() else { + return div().id("file-viewer-none").into_any_element(); + }; + let Some(UnifiedTab::File { index }) = ws.active else { + return div().id("file-viewer-hidden").into_any_element(); + }; + let Some(file) = ws.files.get(index).cloned() else { + return div().id("file-viewer-missing").into_any_element(); + }; + + if file.preview { + return v_flex() + .id("binary-preview") + .size_full() + .child( + h_flex() + .w_full() + .min_h(px(36.)) + .px_3() + .bg(theme.muted) + .justify_between() + .child(div().text_sm().child(file.title.clone())) + .child(div().text_xs().child("Preview")), + ) + .child( + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap_2() + .child(div().text_color(theme.muted_foreground).child(file.preview_kind.clone())) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(if file.binary_note.is_empty() { + "Preview unavailable".to_string() + } else { + file.binary_note + }), + ), + ) + .into_any_element(); + } + + if backend::is_markdown(&file.path) { + return v_flex() + .id("markdown-viewer") + .size_full() + .child( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_1() + .border_b_1() + .border_color(theme.border) + .child(md_btn("md-h1", "H1", "# ", &view)) + .child(md_btn("md-h2", "H2", "## ", &view)) + .child(md_btn("md-h3", "H3", "### ", &view)) + .child(md_btn("md-b", "B", "**", &view)) + .child(md_btn("md-i", "I", "*", &view)) + .child(md_btn("md-code", "`", "`", &view)) + .child( + Button::new("md-save") + .xsmall() + .primary() + .label(app.t("save")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.save_active_file(window, cx); + cx.notify(); + }); + } + }), + ), + ) + .child( + h_flex() + .flex_1() + .min_h_0() + .child( + div() + .flex_1() + .h_full() + .child(Input::new(&app.inputs.file_editor)), + ) + .child( + div() + .flex_1() + .h_full() + .p_3() + .border_l_1() + .border_color(theme.border) + .child(TextView::markdown( + "md-preview", + file.draft.clone(), + window, + cx, + )), + ), + ) + .into_any_element(); + } + + v_flex() + .id("text-viewer") + .size_full() + .child( + h_flex() + .w_full() + .px_2() + .py_1() + .justify_end() + .border_b_1() + .border_color(theme.border) + .child( + Button::new("file-save") + .xsmall() + .primary() + .label(app.t("save")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.save_active_file(window, cx); + cx.notify(); + }); + } + }), + ), + ) + .child(div().flex_1().min_h_0().child(Input::new(&app.inputs.file_editor))) + .into_any_element() +} + +fn md_btn(id: &'static str, label: &'static str, wrap: &'static str, view: &gpui::Entity) -> impl IntoElement { + let view = view.clone(); + Button::new(id) + .ghost() + .xsmall() + .label(label) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + let mut text = app.inputs.file_editor.read(cx).value().to_string(); + text.push_str(wrap); + app.inputs.file_editor.update(cx, |s, cx| { + s.set_value(text, window, cx); + }); + }); + }) +} diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs new file mode 100644 index 00000000..e617b52f --- /dev/null +++ b/src-gpui/src/ui/git.rs @@ -0,0 +1,633 @@ +use gpui::{div, prelude::*, px, rgb, Context, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{Disableable, Selectable}; +use gpui_component::checkbox::Checkbox; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; + +use crate::app::{extract_file_hunk, file_status_badge, AppView}; +use crate::state::{DiffPreviewMode, GitDiffTab}; + +pub fn render_panel(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(ws) = app.data.current_ws() else { + return div().id("git-panel-empty").into_any_element(); + }; + let files = ws.git_files.clone(); + let included = ws.git_included.clone(); + let ahead = ws.git_ahead; + + v_flex() + .id("git-panel") + .size_full() + .child( + v_flex() + .flex_1() + .min_h_0() + .child( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_2() + .child(Checkbox::new("git-all").checked(!files.is_empty() && included.len() == files.len()).on_click({ + let view = view.clone(); + let files = files.clone(); + move |checked, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.git_included.clear(); + if *checked { + ws.git_included.extend(files.iter().map(|(p, _)| p.clone())); + } + } + cx.notify(); + }); + } + })) + .child( + div().flex_1().text_xs().child(crate::i18n::tf( + app.data.locale, + "changedFiles", + &[("count", &files.len().to_string())], + )), + ) + .child( + Button::new("open-diff") + .ghost() + .xsmall() + .icon(IconName::Maximize) + .tooltip(app.t("gitOpenDiffView")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.open_git_diff(); + cx.notify(); + }); + } + }), + ), + ) + .child(if files.is_empty() { + div() + .p_4() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noChangesDetected")) + .into_any_element() + } else { + v_flex() + .children(files.iter().map(|(path, status)| { + file_row(app, path, status, included.contains(path), true, cx) + })) + .into_any_element() + }), + ) + .child(commit_composer(app, included.len(), files.len(), ahead, cx)) + .into_any_element() +} + +fn file_row( + app: &AppView, + path: &str, + status: &str, + checked: bool, + compact: bool, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + let badge = file_status_badge(status); + let name = crate::backend::file_name(path); + let path_owned = path.to_string(); + h_flex() + .id(crate::ui::eid(format!("git-file-{path}"))) + .w_full() + .px_2() + .py_1() + .gap_2() + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let path = path_owned.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.select_diff_file(&path); + app.data.overlay.git_diff_open = true; + cx.notify(); + }); + } + }) + .child(Checkbox::new(crate::ui::eid(format!("chk-{path}"))).checked(checked).on_click({ + let view = view.clone(); + let path = path_owned.clone(); + move |val, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + if *val { + ws.git_included.insert(path.clone()); + } else { + ws.git_included.remove(&path); + } + } + cx.notify(); + }); + } + })) + .child(Icon::new(IconName::File).w(px(13.))) + .child(div().flex_1().text_sm().child(name)) + .child( + div() + .text_xs() + .text_color(match badge { + "A" => theme.success, + "D" => theme.danger, + _ => theme.muted_foreground, + }) + .child(badge), + ) + .when(compact, |el| { + el.child( + Button::new(crate::ui::eid(format!("discard-{path}"))) + .ghost() + .xsmall() + .icon(IconName::Undo) + .tooltip(app.t("gitDiscardFileAction")) + .on_click({ + let view = view.clone(); + let path = path_owned.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.discard_file(&path); + cx.notify(); + }); + } + }), + ) + }) +} + +fn commit_composer( + app: &AppView, + included: usize, + total: usize, + ahead: u32, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + v_flex() + .id("commit-composer") + .w_full() + .border_t_1() + .border_color(theme.border) + .px_2() + .py_2() + .gap_2() + .child( + div() + .text_xs() + .font_semibold() + .child(app.t("gitCommitSectionTitle").to_ascii_uppercase()), + ) + .child(Input::new(&app.inputs.commit_summary)) + .child(Input::new(&app.inputs.commit_body)) + .child( + h_flex() + .justify_between() + .child( + div().text_xs().text_color(theme.muted_foreground).child(crate::i18n::tf( + app.data.locale, + "gitCommitIncludedCount", + &[ + ("includedCount", &included.to_string()), + ("totalCount", &total.to_string()), + ], + )), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("inc-all") + .ghost() + .xsmall() + .label(app.t("gitCommitIncludeAll")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.git_included = + ws.git_files.iter().map(|(p, _)| p.clone()).collect(); + } + cx.notify(); + }); + } + }), + ) + .child( + Button::new("inc-none") + .ghost() + .xsmall() + .label(app.t("gitCommitIncludeNone")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.git_included.clear(); + } + cx.notify(); + }); + } + }), + ), + ), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("commit") + .primary() + .small() + .label(app.t("gitCommitButton")) + .disabled(included == 0) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.commit_selected(cx); + cx.notify(); + }); + } + }), + ) + .when(ahead > 0, |el| { + el.child( + Button::new("push") + .small() + .icon(IconName::ArrowUp) + .label(app.t("gitPushButton")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.push_current(); + cx.notify(); + }); + } + }), + ) + }), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("gitCommitShortcutHint")), + ) +} + +pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !app.data.overlay.git_diff_open { + return div().id("git-diff-closed").into_any_element(); + } + let theme = cx.theme().clone(); + let view = cx.entity(); + let branch = app + .data + .current_ws() + .map(|w| { + if w.branch.is_empty() { + "main".into() + } else { + w.branch.clone() + } + }) + .unwrap_or_else(|| "main".into()); + let tab = app.data.overlay.git_diff_tab; + let mode = app.data.overlay.git_diff_mode; + let files = app + .data + .current_ws() + .map(|w| w.git_files.clone()) + .unwrap_or_default(); + let selected = app.data.overlay.git_diff_file.clone(); + let diff = selected + .as_ref() + .map(|p| extract_file_hunk(&app.data.overlay.git_diff_text, p)) + .unwrap_or_else(|| app.data.overlay.git_diff_text.clone()); + let large = diff.lines().count() >= 400 + && selected + .as_ref() + .map(|p| !app.data.overlay.git_large_revealed.contains(p)) + .unwrap_or(false); + + div() + .id("git-diff-overlay") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::hsla(0., 0., 0., 0.35)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_diff_open = false; + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("git-diff-dialog") + .w(px(1100.)) + .h(px(720.)) + .bg(theme.background) + .rounded_xl() + .border_1() + .border_color(theme.border) + .shadow_lg() + .on_click(|_, _, _| {}) + .child( + h_flex() + .w_full() + .px_4() + .py_2() + .border_b_1() + .border_color(theme.border) + .justify_between() + .child( + h_flex() + .gap_2() + .child(Icon::new(IconName::GitHub).w(px(16.))) + .child(div().font_semibold().child(branch)), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("unified") + .xsmall() + .selected(mode == DiffPreviewMode::Unified) + .label(app.t("gitDiffPreviewModeUnified")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_diff_mode = DiffPreviewMode::Unified; + cx.notify(); + }); + } + }), + ) + .child( + Button::new("split") + .xsmall() + .selected(mode == DiffPreviewMode::Split) + .label(app.t("gitDiffPreviewModeSplit")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_diff_mode = DiffPreviewMode::Split; + cx.notify(); + }); + } + }), + ) + .child( + Button::new("close-diff") + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_diff_open = false; + cx.notify(); + }); + } + }), + ), + ), + ) + .child( + h_flex() + .flex_1() + .min_h_0() + .child( + v_flex() + .w(px(360.)) + .h_full() + .border_r_1() + .border_color(theme.border) + .child( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_2() + .child(tab_btn("changes", app.t("changes"), tab == GitDiffTab::Changes, GitDiffTab::Changes, &view)) + .child(tab_btn("history", app.t("history"), tab == GitDiffTab::History, GitDiffTab::History, &view)), + ) + .child(if tab == GitDiffTab::Changes { + v_flex() + .flex_1() + .min_h_0() + .child(if files.is_empty() { + div().p_4().child(app.t("noChangesDetected")).into_any_element() + } else { + v_flex() + .flex_1() + .children(files.iter().map(|(p, s)| { + file_row(app, p, s, app.data.current_ws().map(|w| w.git_included.contains(p)).unwrap_or(false), false, cx) + })) + .into_any_element() + }) + .child(commit_composer( + app, + app.data.current_ws().map(|w| w.git_included.len()).unwrap_or(0), + files.len(), + app.data.current_ws().map(|w| w.git_ahead).unwrap_or(0), + cx, + )) + .into_any_element() + } else { + history_pane(app, cx).into_any_element() + }), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .h_full() + .p_3() + .child(if selected.is_none() && tab == GitDiffTab::Changes { + div() + .text_color(theme.muted_foreground) + .child(app.t("selectFileToView")) + .into_any_element() + } else if large { + v_flex() + .gap_2() + .child(div().font_semibold().child(app.t("gitDiffLargeGuardrailTitle"))) + .child(div().text_sm().child(app.t("gitDiffLargeGuardrailDescription"))) + .child( + Button::new("reveal-large") + .label(app.t("gitDiffLargeGuardrailReveal")) + .on_click({ + let view = view.clone(); + let path = selected.clone().unwrap_or_default(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_large_revealed.insert(path.clone()); + cx.notify(); + }); + } + }), + ) + .into_any_element() + } else { + diff_view(&diff, mode, theme.muted_foreground).into_any_element() + }), + ), + ), + ) + .into_any_element() +} + +fn tab_btn( + id: &'static str, + label: String, + selected: bool, + tab: GitDiffTab, + view: &gpui::Entity, +) -> impl IntoElement { + let view = view.clone(); + Button::new(id) + .ghost() + .small() + .selected(selected) + .label(label) + .on_click(move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_diff_tab = tab; + cx.notify(); + }); + }) +} + +fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + if let Some(hash) = app.data.overlay.git_selected_commit.clone() { + return v_flex() + .child( + Button::new("back-commits") + .ghost() + .small() + .label(app.t("backToCommitList")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_selected_commit = None; + cx.notify(); + }); + } + }), + ) + .child(if app.data.overlay.git_commit_files.is_empty() { + div().p_3().child(app.t("noFileChanges")).into_any_element() + } else { + v_flex() + .children(app.data.overlay.git_commit_files.iter().map(|p| { + let path = p.clone(); + div() + .id(crate::ui::eid(format!("cfile-{p}"))) + .px_2() + .py_1() + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_diff_file = Some(path.clone()); + cx.notify(); + }); + } + }) + .child(p.clone()) + })) + .into_any_element() + }) + .into_any_element(); + } + if app.data.overlay.git_commits.is_empty() { + return div().p_4().child(app.t("noCommitsFound")).into_any_element(); + } + v_flex().children(app.data.overlay.git_commits.iter().map(|c| { + let hash = c.hash.clone(); + v_flex() + .id(crate::ui::eid(format!("commit-{hash}"))) + .px_3() + .py_2() + .gap_1() + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let hash = hash.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.select_commit(&hash); + cx.notify(); + }); + } + }) + .child(div().font_medium().text_sm().child(c.message.clone())) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(format!("{} · {}", c.author.name, &hash[..hash.len().min(7)])), + ) + })).into_any_element() +} + +fn diff_view(diff: &str, mode: DiffPreviewMode, muted: gpui::Hsla) -> impl IntoElement { + if diff.trim().is_empty() { + return div().text_color(muted).child("").into_any_element(); + } + v_flex() + .id("diff-lines") + .gap_0() + .font_family("monospace") + .text_sm() + .children(diff.lines().map(|line| { + let (bg, color) = if line.starts_with('+') && !line.starts_with("+++") { + (gpui::hsla(0.38, 0.6, 0.4, 0.15), gpui::hsla(0.38, 0.7, 0.45, 1.)) + } else if line.starts_with('-') && !line.starts_with("---") { + (gpui::hsla(0.02, 0.7, 0.5, 0.15), gpui::hsla(0.02, 0.75, 0.5, 1.)) + } else { + (gpui::hsla(0., 0., 0., 0.), muted) + }; + div() + .px_2() + .bg(bg) + .text_color(color) + .child(if mode == DiffPreviewMode::Split && (line.starts_with('+') || line.starts_with('-')) { + format!("{} │ {}", if line.starts_with('+') { " " } else { line }, if line.starts_with('+') { line } else { " " }) + } else { + line.to_string() + }) + })) + .into_any_element() +} diff --git a/src-gpui/src/ui/home.rs b/src-gpui/src/ui/home.rs new file mode 100644 index 00000000..cfeb9a64 --- /dev/null +++ b/src-gpui/src/ui/home.rs @@ -0,0 +1,56 @@ +use gpui::{div, prelude::*, px, Context, Window}; +use gpui_component::{v_flex, ActiveTheme, Icon, IconName, StyledExt}; + +use crate::app::AppView; + +pub fn render(app: &AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let pad_left = if cfg!(target_os = "macos") && app.data.prefs.sidebar_collapsed { + px(84.) + } else { + px(20.) + }; + + v_flex() + .id("home-page") + .size_full() + .child( + div() + .id("home-header") + .h(px(52.)) + .w_full() + .px_5() + .pl(pad_left) + .flex() + .items_center() + .gap_2() + .border_b_1() + .border_color(theme.border) + .child(Icon::new(IconName::Folder).text_color(theme.muted_foreground).w(px(16.))) + .child(div().font_semibold().text_sm().child(app.t("home"))), + ) + .child( + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap_3() + .child( + div() + .size(px(48.)) + .rounded_full() + .bg(theme.muted) + .flex() + .items_center() + .justify_center() + .child(Icon::new(IconName::Folder).w(px(22.))), + ) + .child(div().font_semibold().child(app.t("emptyProjectsTitle"))) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("emptyProjectsDesc")), + ), + ) +} diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs new file mode 100644 index 00000000..2c49a938 --- /dev/null +++ b/src-gpui/src/ui/mod.rs @@ -0,0 +1,19 @@ +use gpui::SharedString; + +pub fn eid(s: impl Into) -> SharedString { + SharedString::from(s.into()) +} + +pub mod debug; +pub mod dialogs; +pub mod file_tree; +pub mod file_viewer; +pub mod git; +pub mod home; +pub mod notes; +pub mod palette; +pub mod settings; +pub mod shell; +pub mod sidebar; +pub mod terminal; +pub mod workspace; diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs new file mode 100644 index 00000000..2305eced --- /dev/null +++ b/src-gpui/src/ui/notes.rs @@ -0,0 +1,57 @@ +use gpui::{div, prelude::*, Context, Window}; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, StyledExt}; + +use crate::app::AppView; +use crate::state::NotesStatus; + +pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let status = app + .data + .current_ws() + .map(|w| w.notes_status) + .unwrap_or_default(); + let badge = match status { + NotesStatus::Saving => app.t("notesSaving"), + NotesStatus::Failed => app.t("notesSaveFailedShort"), + NotesStatus::Saved => app.t("notesSaved"), + }; + + v_flex() + .id("notes-panel") + .size_full() + .child( + h_flex() + .w_full() + .px_2() + .py_1() + .justify_between() + .border_b_1() + .border_color(theme.border) + .child(div().text_xs().font_semibold().child(app.t("notes"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(badge), + ), + ) + .child( + div() + .id("notes-editor") + .flex_1() + .min_h_0() + .p_2() + .on_mouse_up(gpui::MouseButton::Left, { + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.save_notes(cx); + }); + } + }) + .child(Input::new(&app.inputs.notes)), + ) +} diff --git a/src-gpui/src/ui/palette.rs b/src-gpui/src/ui/palette.rs new file mode 100644 index 00000000..36b0e9f8 --- /dev/null +++ b/src-gpui/src/ui/palette.rs @@ -0,0 +1,131 @@ +use gpui::{div, prelude::*, px, Context, Window}; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, StyledExt}; + +use crate::app::AppView; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !app.data.overlay.palette_open { + return div().id("palette-closed").into_any_element(); + } + let theme = cx.theme().clone(); + let view = cx.entity(); + let q = app.inputs.palette.read(cx).value().to_string(); + let results = app.data.overlay.palette_results.clone(); + let selected = app.data.overlay.palette_index; + + div() + .id("palette-mask") + .absolute() + .inset_0() + .bg(gpui::hsla(0., 0., 0., 0.4)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.palette_open = false; + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("command-palette") + .absolute() + .top(px(72.)) + .left_0() + .right_0() + .mx_auto() + .w(px(640.)) + .max_h(px(520.)) + .rounded_lg() + .bg(theme.background) + .border_1() + .border_color(theme.border) + .shadow_lg() + .on_click(|_, _, _| {}) + .child( + div() + .px_4() + .py_3() + .border_b_1() + .border_color(theme.border) + .child(Input::new(&app.inputs.palette)), + ) + .child(if q.is_empty() { + div() + .p_4() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("commandPaletteEmpty")) + .into_any_element() + } else if results.is_empty() { + v_flex() + .p_4() + .gap_1() + .child(div().child(app.t("commandPaletteNoResults"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("commandPaletteNoResultsHint")), + ) + .into_any_element() + } else { + v_flex() + .max_h(px(360.)) + .children(results.iter().enumerate().map(|(ix, r)| { + h_flex() + .id(crate::ui::eid(format!("pal-{ix}"))) + .px_3() + .py_2() + .gap_2() + .when(ix == selected, |el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.data.overlay.palette_index = ix; + app.open_palette_selection(window, cx); + cx.notify(); + }); + } + }) + .child(Icon::new(IconName::File).w(px(16.))) + .child(div().text_sm().child(r.name.clone())) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(r.relative_path.clone()), + ) + })) + .into_any_element() + }) + .child( + h_flex() + .px_3() + .py_2() + .border_t_1() + .border_color(theme.border) + .justify_between() + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(crate::i18n::tf( + app.data.locale, + "commandPaletteResultCount", + &[("count", &results.len().to_string())], + )), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("commandPaletteFooterHint")), + ), + ), + ) + .into_any_element() +} diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs new file mode 100644 index 00000000..87faa9aa --- /dev/null +++ b/src-gpui/src/ui/settings.rs @@ -0,0 +1,745 @@ +use gpui::{ + div, point, prelude::*, px, size, App, Bounds, Context, SharedString, Window, WindowBounds, + WindowOptions, +}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{Disableable, Selectable}; +use gpui_component::input::Input; +use gpui_component::switch::Switch; +use gpui_component::{ + h_flex, v_flex, ActiveTheme, IconName, Root, Sizable, StyledExt, ThemeMode, +}; +use gpui_component::tab::Tab; + +use crate::app::AppView; +use crate::i18n::Locale; +use crate::prefs::{term_theme_by_name, RadiusPref, TERM_THEMES, ThemePref}; +use crate::state::SettingsTab; + +pub fn open_settings_window(app: &mut AppView, window: &mut Window, cx: &mut Context) { + if let Some(handle) = app.settings_window.clone() { + if handle.update(cx, |_, win, _| win.activate_window()).is_ok() { + app.data.overlay.settings_open = true; + return; + } + } + let backend = app.backend.clone(); + let prefs = app.data.prefs.clone(); + let locale = app.data.locale; + let main = cx.entity().downgrade(); + let bounds = Bounds::centered(None, size(px(880.), px(640.)), cx); + if let Ok(handle) = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(600.), px(420.))), + titlebar: Some(gpui::TitlebarOptions { + title: Some(SharedString::from("Settings")), + appears_transparent: false, + traffic_light_position: Some(point(px(12.), px(12.))), + }), + ..Default::default() + }, + move |window, cx| { + let view = cx.new(|cx| SettingsView::new(backend.clone(), prefs.clone(), locale, main.clone(), window, cx)); + cx.new(|cx| Root::new(view, window, cx)) + }, + ) { + app.settings_window = Some(handle); + app.data.overlay.settings_open = true; + } + let _ = window; +} + +pub struct SettingsView { + backend: crate::backend::Backend, + prefs: crate::prefs::Prefs, + locale: Locale, + tab: SettingsTab, + main: gpui::WeakEntity, + custom_shell: gpui::Entity, + worktree: gpui::Entity, + template_name: gpui::Entity, + template_shell: gpui::Entity, + template_cwd: gpui::Entity, + template_cmds: gpui::Entity, + update_status: String, +} + +impl SettingsView { + fn new( + backend: crate::backend::Backend, + prefs: crate::prefs::Prefs, + locale: Locale, + main: gpui::WeakEntity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + fn inp( + window: &mut Window, + cx: &mut Context, + ph: &str, + val: &str, + multi: bool, + ) -> gpui::Entity { + let ph = ph.to_string(); + let val = val.to_string(); + cx.new(|cx| { + let mut s = gpui_component::input::InputState::new(window, cx).placeholder(ph); + if multi { + s = s.multi_line(true); + } + s + }) + } + let custom_shell = inp(window, cx, "", &prefs.custom_shell, false); + let worktree = inp(window, cx, "", &prefs.worktree_dir, false); + custom_shell.update(cx, |s, cx| { + s.set_value(prefs.custom_shell.clone(), window, cx); + }); + worktree.update(cx, |s, cx| { + s.set_value(prefs.worktree_dir.clone(), window, cx); + }); + Self { + backend, + prefs, + locale, + tab: SettingsTab::General, + main, + custom_shell, + worktree, + template_name: inp(window, cx, "", "", false), + template_shell: inp(window, cx, "", "", false), + template_cwd: inp(window, cx, "", "", false), + template_cmds: inp(window, cx, "", "", true), + update_status: crate::i18n::t(locale, "updateIdleDescription"), + } + } + + fn t(&self, key: &str) -> String { + crate::i18n::t(self.locale, key) + } + + fn persist(&mut self, cx: &mut Context) { + self.prefs.custom_shell = self.custom_shell.read(cx).value().to_string(); + self.prefs.worktree_dir = self.worktree.read(cx).value().to_string(); + self.prefs.language = self.locale; + self.prefs.save(&self.backend.app_data_dir); + if let Some(main) = self.main.upgrade() { + let prefs = self.prefs.clone(); + let locale = self.locale; + main.update(cx, |app, cx| { + app.data.prefs = prefs; + app.data.locale = locale; + app.persist_prefs(); + cx.notify(); + }); + } + } +} + +impl gpui::Render for SettingsView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let tab = self.tab; + v_flex() + .id("settings-window") + .size_full() + .bg(theme.background) + .child( + h_flex() + .id("settings-tabs") + .w_full() + .px_5() + .pt_5() + .gap_2() + .overflow_x_scroll() + .child(set_tab("g", IconName::Settings, self.t("general"), tab == SettingsTab::General, SettingsTab::General, cx)) + .child(set_tab("t", IconName::SquareTerminal, self.t("terminal"), tab == SettingsTab::Terminal, SettingsTab::Terminal, cx)) + .child(set_tab("tp", IconName::LayoutDashboard, self.t("templates"), tab == SettingsTab::Templates, SettingsTab::Templates, cx)) + .child(set_tab("n", IconName::Bell, self.t("notification"), tab == SettingsTab::Notification, SettingsTab::Notification, cx)) + .child(set_tab("tb", IconName::PanelBottom, self.t("topbar"), tab == SettingsTab::TopBar, SettingsTab::TopBar, cx)) + .child(set_tab("a", IconName::Info, self.t("about"), tab == SettingsTab::About, SettingsTab::About, cx)), + ) + .child( + div() + .id("settings-body") + .flex_1() + .min_h_0() + .p_5() + .child(match self.tab { + SettingsTab::General => self.general(window, cx).into_any_element(), + SettingsTab::Terminal => self.terminal(window, cx).into_any_element(), + SettingsTab::Templates => self.templates(window, cx).into_any_element(), + SettingsTab::Notification => self.notification(cx).into_any_element(), + SettingsTab::TopBar => self.topbar(cx).into_any_element(), + SettingsTab::About => self.about(cx).into_any_element(), + }), + ) + } +} + +impl SettingsView { + fn general(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + v_flex() + .id("settings-general") + .max_w(px(448.)) + .gap_6() + .child(field_label(&self.t("language"))) + .child( + h_flex() + .gap_2() + .child(choice("lang-en", "English", self.locale == Locale::En, { + let view = view.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.locale = Locale::En; + this.persist(cx); + cx.notify(); + }); + } + })) + .child(choice("lang-zh", "中文", self.locale == Locale::Zh, { + let view = view.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.locale = Locale::Zh; + this.persist(cx); + cx.notify(); + }); + } + })), + ) + .child(field_label(&self.t("theme"))) + .child( + h_flex() + .gap_2() + .children( + [ + (ThemePref::System, self.t("themeSystem")), + (ThemePref::Light, self.t("themeLight")), + (ThemePref::Dark, self.t("themeDark")), + ] + .into_iter() + .map(|(pref, label)| { + let selected = self.prefs.theme == pref; + choice(format!("theme-{label}"), &label, selected, { + let view = view.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.theme = pref; + let mode = match pref { + ThemePref::Dark => ThemeMode::Dark, + ThemePref::Light => ThemeMode::Light, + ThemePref::System => cx.window_appearance().into(), + }; + gpui_component::Theme::change(mode, None, cx); + this.persist(cx); + cx.notify(); + }); + } + }) + }), + ), + ) + .child(field_label(&self.t("borderRadius"))) + .child( + h_flex().gap_2().children(RadiusPref::all().into_iter().map(|r| { + let selected = self.prefs.radius == r; + choice(r.label_key(), &self.t(r.label_key()), selected, { + let view = view.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.radius = r; + this.persist(cx); + cx.notify(); + }); + } + }) + })), + ) + .child(field_label(&self.t("defaultWorktreeDir"))) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("defaultWorktreeDirDesc")), + ) + .child(Input::new(&self.worktree)) + .child(switch_row( + "debug-mode", + self.t("debugMode"), + self.t("debugModeDescription"), + self.prefs.debug_mode, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.debug_mode = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + .child(switch_row( + "perf", + self.t("performanceProfile"), + self.t("performanceProfileDescription"), + self.prefs.performance_profile, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.performance_profile = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + .child(switch_row( + "avatars", + self.t("showProjectAvatars"), + self.t("showProjectAvatarsDescription"), + self.prefs.show_avatars, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.show_avatars = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + } + + fn terminal(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let theme = term_theme_by_name(&self.prefs.terminal_theme_dark); + h_flex() + .id("settings-terminal") + .size_full() + .gap_8() + .child( + v_flex() + .max_w(px(448.)) + .gap_4() + .child(field_label(&self.t("terminalTheme"))) + .child( + v_flex().gap_1().children(TERM_THEMES.iter().map(|t| { + let selected = self.prefs.terminal_theme_dark == t.name; + choice(t.name, t.name, selected, { + let view = view.clone(); + let name = t.name.to_string(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.terminal_theme_dark = name.clone(); + if this.prefs.sync_terminal_theme { + this.prefs.terminal_theme_light = name.clone(); + } + this.persist(cx); + cx.notify(); + }); + } + }) + })), + ) + .child(switch_row( + "sync-term", + self.t("syncTerminalTheme"), + String::new(), + self.prefs.sync_terminal_theme, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.sync_terminal_theme = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + .child(field_label(&self.t("defaultShell"))) + .child(Input::new(&self.custom_shell)) + .child(field_label(&self.t("terminalFont"))) + .child(div().text_sm().child(self.prefs.font_family.clone())) + .child(field_label(&self.t("fontSize"))) + .child( + h_flex() + .gap_2() + .child( + Button::new("fs-") + .xsmall() + .label("-") + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.prefs.font_size = (this.prefs.font_size - 1.0).max(10.0); + this.persist(cx); + cx.notify(); + }); + } + }), + ) + .child(div().child(format!("{}", self.prefs.font_size as i32))) + .child( + Button::new("fs+") + .xsmall() + .label("+") + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.prefs.font_size = (this.prefs.font_size + 1.0).min(22.0); + this.persist(cx); + cx.notify(); + }); + } + }), + ), + ), + ) + .child( + v_flex() + .flex_1() + .h(px(220.)) + .rounded_lg() + .p_3() + .bg(gpui::rgb(theme.bg)) + .text_color(gpui::rgb(theme.fg)) + .font_family("monospace") + .child("$ echo 2code") + .child("2code") + .child(format!("theme: {}", theme.name)), + ) + } + + fn templates(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + v_flex() + .id("settings-templates") + .max_w(px(672.)) + .gap_3() + .child(div().font_semibold().child(self.t("globalTerminalTemplates"))) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("globalTerminalTemplatesDescription"))) + .child(if self.prefs.templates.is_empty() { + div().p_4().child(self.t("noTerminalTemplates")).into_any_element() + } else { + v_flex() + .gap_2() + .children(self.prefs.templates.iter().cloned().map(|t| { + h_flex() + .id(crate::ui::eid(format!("tpl-{}", t.id))) + .justify_between() + .p_2() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .child( + v_flex() + .child(div().font_medium().child(t.name.clone())) + .child(div().text_xs().child(format!("{} · {}", t.shell, t.cwd))), + ) + .child( + Button::new(crate::ui::eid(format!("del-{}", t.id))) + .danger() + .xsmall() + .label(self.t("delete")) + .on_click({ + let view = view.clone(); + let id = t.id.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.prefs.templates.retain(|x| x.id != id); + this.persist(cx); + cx.notify(); + }); + } + }), + ) + })) + .into_any_element() + }) + .child(field_label(&self.t("terminalTemplateName"))) + .child(Input::new(&self.template_name)) + .child(field_label(&self.t("terminalTemplateShell"))) + .child(Input::new(&self.template_shell)) + .child(field_label(&self.t("terminalTemplateCwd"))) + .child(Input::new(&self.template_cwd)) + .child(field_label(&self.t("terminalTemplateCommands"))) + .child(Input::new(&self.template_cmds)) + .child( + Button::new("add-tpl") + .primary() + .label(self.t("addTerminalTemplate")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + let name = this.template_name.read(cx).value().to_string(); + if name.trim().is_empty() { + return; + } + this.prefs.templates.push(crate::prefs::TerminalTemplatePref { + id: uuid::Uuid::new_v4().to_string(), + name, + shell: this.template_shell.read(cx).value().to_string(), + cwd: this.template_cwd.read(cx).value().to_string(), + commands: this + .template_cmds + .read(cx) + .value() + .lines() + .map(|l| l.to_string()) + .filter(|l| !l.is_empty()) + .collect(), + }); + this.persist(cx); + cx.notify(); + }); + } + }), + ) + } + + fn notification(&mut self, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + v_flex() + .gap_4() + .max_w(px(448.)) + .child(switch_row( + "notif", + self.t("notificationEnabled"), + self.t("notificationEnabledDescription"), + self.prefs.notifications, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.notifications = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + .child(field_label(&self.t("notificationSound"))) + .child(div().text_sm().child(if self.prefs.notification_sound.is_empty() { + self.t("notificationSoundNone") + } else { + self.prefs.notification_sound.clone() + })) + } + + fn topbar(&mut self, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let all = ["github-desktop", "editor", "terminal", "pr-status"]; + v_flex() + .gap_4() + .child(div().font_semibold().child(self.t("topbarPreview"))) + .child( + h_flex().gap_2().children(self.prefs.topbar_controls.iter().cloned().map(|id| { + div().px_2().py_1().rounded_md().border_1().border_color(cx.theme().border).child(id) + })), + ) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDragHint"))) + .child(div().font_semibold().child(self.t("topbarAvailable"))) + .children(all.into_iter().map(|id| { + let on = self.prefs.topbar_controls.iter().any(|x| x == id); + switch_row( + id, + match id { + "github-desktop" => self.t("topbarGithubDesktop"), + "editor" => self.t("topbarEditor"), + "terminal" => self.t("topbarTerminal"), + _ => self.t("topbarPrStatus"), + }, + String::new(), + on, + { + let view = view.clone(); + let id = id.to_string(); + move |val, cx| { + view.update(cx, |this, cx| { + if val { + if !this.prefs.topbar_controls.contains(&id) { + this.prefs.topbar_controls.push(id.clone()); + } + } else { + this.prefs.topbar_controls.retain(|x| x != &id); + } + this.persist(cx); + cx.notify(); + }); + } + }, + ) + })) + .child( + Button::new("reset-topbar") + .label(self.t("topbarResetDefaults")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.prefs.topbar_controls = vec![ + "github-desktop".into(), + "editor".into(), + "terminal".into(), + "pr-status".into(), + ]; + this.persist(cx); + cx.notify(); + }); + } + }), + ) + } + + fn about(&mut self, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + v_flex() + .gap_3() + .max_w(px(480.)) + .child(div().text_xl().font_semibold().child("2Code")) + .child(div().text_sm().child(self.t("aboutAppDescription"))) + .child( + Button::new("ver") + .label(crate::i18n::tf( + self.locale, + "currentVersion", + &[("version", env!("CARGO_PKG_VERSION"))], + )) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + env!("CARGO_PKG_VERSION").into(), + )); + view.update(cx, |this, cx| { + if let Some(main) = this.main.upgrade() { + main.update(cx, |app, cx| { + app.data.push_toast( + crate::state::ToastKind::Success, + app.t("aboutVersionCopied"), + "", + ); + cx.notify(); + }); + } + }); + } + }), + ) + .child(div().font_semibold().child(self.t("contributors"))) + .child(div().text_sm().child("AkaraChen")) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("primaryContributorDescription"))) + .child( + h_flex() + .gap_2() + .child(link_btn("repo", self.t("repository"), "https://github.com/AkaraChen/2code")) + .child(link_btn("rel", self.t("releases"), "https://github.com/AkaraChen/2code/releases")) + .child(link_btn("web", self.t("website"), "https://github.com/AkaraChen/2code")), + ) + .child(div().text_sm().child(self.update_status.clone())) + .child( + Button::new("check-upd") + .label(self.t("checkForUpdates")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.update_status = this.t("updateNotAvailableDescription"); + cx.notify(); + }); + } + }), + ) + .child(switch_row( + "beta", + self.t("acceptBetaUpdates"), + self.t("acceptBetaUpdatesDescription"), + self.prefs.accept_beta, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.accept_beta = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) + } +} + +fn set_tab( + _id: &'static str, + icon: IconName, + label: String, + selected: bool, + tab: SettingsTab, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + Tab::new() + .icon(icon) + .label(label) + .selected(selected) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.tab = tab; + cx.notify(); + }); + }) +} + +fn field_label(text: &str) -> impl IntoElement { + div().font_medium().text_sm().child(text.to_string()) +} + +fn choice( + id: impl Into, + label: &str, + selected: bool, + on: impl Fn(&mut App) + 'static, +) -> impl IntoElement { + Button::new(id.into()) + .small() + .selected(selected) + .label(label.to_string()) + .on_click(move |_, _, cx| on(cx)) +} + +fn switch_row( + id: &'static str, + title: String, + desc: String, + checked: bool, + on: impl Fn(bool, &mut App) + 'static, +) -> impl IntoElement { + h_flex() + .w_full() + .justify_between() + .gap_4() + .child( + v_flex() + .child(div().font_medium().text_sm().child(title)) + .when(!desc.is_empty(), |el| { + el.child(div().text_xs().child(desc)) + }), + ) + .child(Switch::new(id).checked(checked).on_click(move |val, _, cx| on(*val, cx))) +} + +fn link_btn(id: &'static str, label: String, url: &'static str) -> impl IntoElement { + Button::new(id).small().label(label).on_click(move |_, _, _| { + let _ = open::that(url); + }) +} diff --git a/src-gpui/src/ui/shell.rs b/src-gpui/src/ui/shell.rs new file mode 100644 index 00000000..723a8b30 --- /dev/null +++ b/src-gpui/src/ui/shell.rs @@ -0,0 +1,116 @@ +use gpui::{div, prelude::*, px, rgb, Context, Window}; +use gpui_component::{ActiveTheme, StyledExt}; + +use crate::app::AppView; +use crate::state::Route; +use crate::ui::{debug, dialogs, palette, sidebar, workspace}; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + div() + .flex() + .size_full() + .bg(theme.background) + .text_color(theme.foreground) + .child(sidebar::render(app, window, cx)) + .child( + div() + .id("main-column") + .flex() + .flex_col() + .flex_1() + .min_w_0() + .min_h_0() + .bg(theme.background) + .child(match app.data.route { + Route::Home => home_or_empty(app, window, cx), + Route::Workspace => workspace::render(app, window, cx).into_any_element(), + }) + .when(cfg!(target_os = "windows"), |el| el.child(window_controls())), + ) + .child(dialogs::render(app, window, cx)) + .child(palette::render(app, window, cx)) + .child(crate::ui::git::render_diff_dialog(app, window, cx)) + .child(debug::render_fab(app, window, cx)) + .child(debug::render_panel(app, window, cx)) + .child(toasts(app, cx)) +} + +fn home_or_empty( + app: &mut AppView, + window: &mut Window, + cx: &mut Context, +) -> gpui::AnyElement { + crate::ui::home::render(app, window, cx).into_any_element() +} + +fn toasts(app: &AppView, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + div() + .id("toasts") + .absolute() + .bottom_4() + .right_4() + .flex() + .flex_col() + .gap_2() + .children(app.data.toasts.iter().rev().take(4).map(|toast| { + let bg = theme.background; + div() + .id(crate::ui::eid(format!("toast-{}", toast.id))) + .w(px(320.)) + .px_3() + .py_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(bg) + .shadow_md() + .child( + div() + .text_sm() + .font_semibold() + .child(toast.title.clone()), + ) + .when(!toast.body.is_empty(), |el| { + el.child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(toast.body.clone()), + ) + }) + })) +} + +fn window_controls() -> impl IntoElement { + div() + .id("win-controls") + .absolute() + .top_0() + .right_0() + .h(px(28.)) + .flex() + .child(win_btn("min", "–", false)) + .child(win_btn("max", "□", false)) + .child(win_btn("close", "×", true)) +} + +fn win_btn(id: &'static str, label: &'static str, close: bool) -> impl IntoElement { + div() + .id(id) + .h(px(28.)) + .w(px(36.)) + .flex() + .items_center() + .justify_center() + .text_xs() + .hover(|el| { + if close { + el.bg(rgb(0xc42b1c)).text_color(gpui::white()) + } else { + el.bg(gpui::hsla(0., 0., 0.5, 0.12)) + } + }) + .child(label) +} diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs new file mode 100644 index 00000000..ea3ad84c --- /dev/null +++ b/src-gpui/src/ui/sidebar.rs @@ -0,0 +1,609 @@ +use gpui::{div, prelude::*, px, Context, MouseButton, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{Disableable, Selectable}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; + +use crate::app::AppView; +use crate::backend; +use crate::state::{AgentStatus, ContextMenu, DialogKind, Route}; +use crate::ui::settings; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if app.data.prefs.sidebar_collapsed { + return div().id("app-sidebar-collapsed").into_any_element(); + } + + let theme = cx.theme().clone(); + let width = app.data.prefs.sidebar_width.clamp(220.0, 420.0); + let view = cx.entity(); + let has_projects = !app.data.projects.is_empty(); + let pad_top = if cfg!(target_os = "macos") { px(32.) } else { px(8.) }; + + v_flex() + .id("app-sidebar") + .w(px(width)) + .h_full() + .bg(theme.sidebar) + .border_r_1() + .border_color(theme.border) + .child( + h_flex() + .id("sidebar-header") + .w_full() + .pt(pad_top) + .px_3() + .pb_2() + .justify_between() + .child( + div() + .font_semibold() + .text_sm() + .child("2Code"), + ) + .child( + Button::new("collapse-sidebar") + .ghost() + .xsmall() + .icon(IconName::PanelLeftClose) + .tooltip(app.t("collapseSidebar")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.prefs.sidebar_collapsed = true; + app.persist_prefs(); + cx.notify(); + }); + } + }), + ), + ) + .child( + v_flex() + .id("sidebar-content") + .flex_1() + .min_h_0() + .px_2() + .py_1() + .gap_1() + .overflow_hidden() + .when(!has_projects, |el| { + el.child(nav_row( + "home-row", + IconName::Inbox, + app.t("home"), + app.data.route == Route::Home, + { + let view = view.clone(); + move |cx| { + view.update(cx, |app, cx| { + app.data.route = Route::Home; + cx.notify(); + }); + } + }, + )) + }) + .when_some(app.data.sidebar_error.clone(), |el, err| { + el.child( + v_flex() + .p_4() + .gap_2() + .child( + div() + .text_color(theme.danger) + .font_semibold() + .child(app.t("somethingWentWrong")), + ) + .child(div().text_xs().child(err)) + .child( + Button::new("sidebar-retry") + .xsmall() + .label(app.t("tryAgain")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.reload_projects(); + cx.notify(); + }); + } + }), + ), + ) + }) + .child(project_sections(app, window, cx)), + ) + .child( + h_flex() + .id("sidebar-footer") + .w_full() + .px_2() + .py_2() + .border_t_1() + .border_color(theme.border) + .child( + Button::new("open-settings") + .ghost() + .small() + .icon(IconName::Settings) + .label(app.t("settings")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + settings::open_settings_window(app, window, cx); + }); + } + }), + ), + ) + .into_any_element() +} + +fn project_sections( + app: &mut AppView, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + let pinned: Vec<_> = app + .data + .projects + .iter() + .filter(|p| p.pinned_at.is_some()) + .cloned() + .collect(); + let ungrouped: Vec<_> = app + .data + .projects + .iter() + .filter(|p| p.pinned_at.is_none() && p.group_id.is_none()) + .cloned() + .collect(); + let groups = app.data.groups.clone(); + + v_flex() + .w_full() + .gap_2() + .when(!pinned.is_empty() || app.data.overlay.sort_mode, |el| { + el.child(section_label(&app.t("pinnedProjects"))).child( + v_flex().gap_1().children(pinned.into_iter().map(|p| { + project_row(app, &p, cx) + })), + ) + }) + .child( + h_flex() + .w_full() + .justify_between() + .px_1() + .child(section_label(&app.t("sidebarProjectsSection"))) + .child( + h_flex() + .gap_1() + .child( + Button::new("sort-mode") + .ghost() + .xsmall() + .icon(if app.data.overlay.sort_mode { + IconName::Check + } else { + IconName::EllipsisVertical + }) + .tooltip(if app.data.overlay.sort_mode { + app.t("doneEditingProjectOrder") + } else { + app.t("editProjectOrder") + }) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.sort_mode = !app.data.overlay.sort_mode; + cx.notify(); + }); + } + }), + ) + .child( + Button::new("add-project-button") + .ghost() + .xsmall() + .icon(IconName::Plus) + .tooltip(app.t("newProject")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = Some(DialogKind::CreateProject); + app.data.overlay.dialog_folder = None; + app.data.overlay.dialog_error = None; + app.data.overlay.onboarding = false; + cx.notify(); + }); + } + }), + ), + ), + ) + .child( + v_flex() + .gap_1() + .children(groups.into_iter().map(|group| { + let collapsed = app.data.prefs.collapsed_groups.contains(&group.id); + let members: Vec<_> = app + .data + .projects + .iter() + .filter(|p| p.group_id.as_deref() == Some(group.id.as_str()) && p.pinned_at.is_none()) + .cloned() + .collect(); + let count = members.len(); + let gid = group.id.clone(); + v_flex() + .id(crate::ui::eid(format!("group-{}", group.id))) + .child( + h_flex() + .id(crate::ui::eid(format!("group-h-{}", group.id))) + .px_2() + .py_1() + .rounded_md() + .gap_2() + .hover(|el| el.bg(theme.sidebar_accent)) + .on_click({ + let view = view.clone(); + let gid = gid.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ix) = + app.data.prefs.collapsed_groups.iter().position(|x| x == &gid) + { + app.data.prefs.collapsed_groups.remove(ix); + } else { + app.data.prefs.collapsed_groups.push(gid.clone()); + } + app.persist_prefs(); + cx.notify(); + }); + } + }) + .child(Icon::new(if collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }).w(px(12.))) + .child(div().flex_1().text_sm().child(group.name.clone())) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(count.to_string()), + ), + ) + .when(!collapsed, |el| { + el.child( + v_flex() + .pl_3() + .children(members.into_iter().map(|p| project_row(app, &p, cx))), + ) + }) + })) + .children(ungrouped.into_iter().map(|p| project_row(app, &p, cx))), + ) + .when(app.data.overlay.sort_mode, |el| { + el.child( + div() + .mt_2() + .px_2() + .py_3() + .rounded_md() + .border_1() + .border_dashed() + .border_color(theme.border) + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("dropHereToUnpinOrMoveOut")), + ) + }) + .when(app.data.overlay.onboarding && !has_any_projects(app), |el| { + el.child( + v_flex() + .mt_2() + .p_3() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.background) + .gap_1() + .child(div().font_semibold().text_sm().child(app.t("onboardingTourTitle"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("onboardingTourDesc")), + ), + ) + }) +} + +fn has_any_projects(app: &AppView) -> bool { + !app.data.projects.is_empty() +} + +fn section_label(text: &str) -> impl IntoElement { + div() + .px_2() + .pt_2() + .text_xs() + .font_medium() + .child(text.to_string()) +} + +fn project_row( + app: &AppView, + project: &model::project::ProjectWithProfiles, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let selected = app.data.current_project.as_deref() == Some(project.id.as_str()); + let extras: Vec<_> = project.profiles.iter().filter(|p| !p.is_default).cloned().collect(); + let expanded = app.data.overlay.expanded_projects.contains(&project.id) + || extras.is_empty() + || selected; + let agent = app.agent_for_project(&project.id); + let letter = project + .name + .chars() + .next() + .map(|c| c.to_ascii_uppercase()) + .unwrap_or('?'); + let id = project.id.clone(); + let default_profile = project + .profiles + .iter() + .find(|p| p.is_default) + .map(|p| p.id.clone()); + + v_flex() + .id(crate::ui::eid(format!("proj-{}", project.id))) + .child( + h_flex() + .id(crate::ui::eid(format!("proj-row-{}", project.id))) + .w_full() + .px_2() + .py_1() + .rounded_md() + .gap_2() + .when(selected, |el| el.bg(theme.sidebar_accent)) + .hover(|el| el.bg(theme.sidebar_accent)) + .on_click({ + let view = view.clone(); + let id = id.clone(); + let default_profile = default_profile.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(pid) = default_profile.clone() { + app.open_profile(&id, &pid); + } + cx.notify(); + }); + } + }) + .on_mouse_down(MouseButton::Right, { + let view = view.clone(); + let id = id.clone(); + move |ev, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.context_menu = + Some((ContextMenu::Project { id: id.clone() }, ev.position.x.into(), ev.position.y.into())); + cx.notify(); + }); + } + }) + .when(app.data.prefs.show_avatars, |el| { + el.child( + div() + .size(px(16.)) + .rounded_md() + .bg(theme.sidebar_accent) + .flex() + .items_center() + .justify_center() + .text_xs() + .child(letter.to_string()), + ) + }) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .font_medium() + .whitespace_nowrap() + .overflow_hidden() + .child(project.name.clone()), + ) + .when(app.data.overlay.sort_mode, |el| { + let pinned = project.pinned_at.is_some(); + el.child( + Button::new(crate::ui::eid(format!("pin-{}", project.id))) + .ghost() + .xsmall() + .icon(if pinned { IconName::Star } else { IconName::StarOff }) + .tooltip(if pinned { + app.t("unpinProject") + } else { + app.t("pinProject") + }) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + let _ = app.backend.set_pinned(&id, !pinned); + app.reload_projects(); + cx.notify(); + }); + } + }), + ) + }) + .when(!app.data.overlay.sort_mode && extras.is_empty(), |el| { + el.child(agent_dot(agent)).child( + Button::new(crate::ui::eid(format!("add-prof-{}", project.id))) + .ghost() + .xsmall() + .icon(IconName::Plus) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = Some(DialogKind::CreateProfile); + app.data.overlay.dialog_project = Some(id.clone()); + cx.notify(); + }); + } + }), + ) + }) + .when(!extras.is_empty(), |el| { + el.child(Icon::new(if expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }).w(px(12.))) + }), + ) + .when(expanded, |el| { + el.child( + v_flex() + .pl_6() + .gap_0() + .children(project.profiles.iter().map(|profile| { + let pid = profile.id.clone(); + let proj = project.id.clone(); + let selected = app.data.current_profile.as_deref() == Some(profile.id.as_str()); + let label = if profile.is_default { + app.t("defaultProfile") + } else { + profile.branch_name.clone() + }; + h_flex() + .id(crate::ui::eid(format!("prof-{}", profile.id))) + .px_2() + .py_1() + .rounded_md() + .gap_2() + .when(selected, |el| el.bg(theme.sidebar_accent)) + .hover(|el| el.bg(theme.sidebar_accent)) + .on_click({ + let view = view.clone(); + let pid = pid.clone(); + let proj = proj.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.open_profile(&proj, &pid); + cx.notify(); + }); + } + }) + .on_mouse_down(MouseButton::Right, { + let view = view.clone(); + let pid = pid.clone(); + let proj = proj.clone(); + let is_default = profile.is_default; + move |ev, _, cx| { + if is_default { + return; + } + view.update(cx, |app, cx| { + app.data.overlay.context_menu = Some(( + ContextMenu::Profile { + id: pid.clone(), + project_id: proj.clone(), + }, + ev.position.x.into(), + ev.position.y.into(), + )); + cx.notify(); + }); + } + }) + .child(Icon::new(if profile.is_default { + IconName::SquareTerminal + } else { + IconName::GitHub + }).w(px(14.))) + .child(div().flex_1().text_sm().child(label)) + .child(agent_dot(app.agent_for_profile(&profile.id))) + })) + .child( + h_flex() + .id(crate::ui::eid(format!("new-prof-{}", project.id))) + .px_2() + .py_1() + .rounded_md() + .gap_2() + .hover(|el| el.bg(theme.sidebar_accent)) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = Some(DialogKind::CreateProfile); + app.data.overlay.dialog_project = Some(id.clone()); + cx.notify(); + }); + } + }) + .child(Icon::new(IconName::Plus).w(px(12.))) + .child(div().text_sm().child(app.t("createProfile"))), + ), + ) + }) +} + +fn nav_row( + id: &'static str, + icon: IconName, + label: String, + selected: bool, + on_click: impl Fn(&mut gpui::App) + 'static, +) -> impl IntoElement { + div() + .id(id) + .px_2() + .py_1() + .rounded_md() + .when(selected, |el| el.bg(gpui::hsla(0., 0., 0.5, 0.08))) + .on_click(move |_, _, cx| on_click(cx)) + .child( + h_flex() + .gap_2() + .child(Icon::new(icon).w(px(14.))) + .child(div().text_sm().child(label)), + ) +} + +pub fn agent_dot(status: AgentStatus) -> impl IntoElement { + let (color, pulse) = match status { + AgentStatus::Waiting => (gpui::rgb(0xfacc15), false), + AgentStatus::Running => (gpui::rgb(0x34d399), true), + AgentStatus::Completed => (gpui::rgb(0x22c55e), false), + AgentStatus::Idle => (gpui::rgb(0x000000), false), + }; + div() + .size(px(8.)) + .rounded_full() + .bg(color) + .when(pulse, |el| el.opacity(0.85)) +} + +#[allow(dead_code)] +fn _file_name(path: &str) -> String { + backend::file_name(path) +} diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs new file mode 100644 index 00000000..1f4f85c9 --- /dev/null +++ b/src-gpui/src/ui/terminal.rs @@ -0,0 +1,134 @@ +use gpui::{div, prelude::*, px, rgb, Context, KeyDownEvent, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; + +use crate::app::AppView; + +pub fn render( + app: &mut AppView, + index: usize, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let theme = app.current_term_theme(); + let view = cx.entity(); + let Some(term) = app + .data + .current_ws() + .and_then(|w| w.terminals.get(index)) + else { + return div().id("term-missing").into_any_element(); + }; + let text = term.screen_text(); + let search_open = term.search_open; + let id = term.id.clone(); + + div() + .id(crate::ui::eid(format!("pty-{id}"))) + .size_full() + .relative() + .bg(rgb(theme.bg)) + .text_color(rgb(theme.fg)) + .font_family(app.data.prefs.font_family.clone()) + .text_size(px(app.data.prefs.font_size)) + .child( + div() + .id(crate::ui::eid(format!("pty-body-{id}"))) + .size_full() + .p_2() + .font_family("monospace") + .whitespace_nowrap() // may fail + .on_key_down({ + let view = view.clone(); + move |ev: &KeyDownEvent, _, cx| { + if let Some(bytes) = key_to_bytes(ev) { + view.update(cx, |app, _| { + app.write_to_active_pty(&bytes); + }); + } + } + }) + .child( + v_flex().children(text.lines().map(|line| { + div().child(line.to_string()) + })), + ), + ) + .when(search_open, |el| { + el.child( + h_flex() + .id("term-search") + .absolute() + .top(px(12.)) + .right(px(12.)) + .w(px(280.)) + .h(px(28.)) + .px_2() + .gap_1() + .rounded_md() + .bg(cx.theme().background) + .border_1() + .border_color(cx.theme().border) + .shadow_md() + .child(div().flex_1().child(Input::new(&app.inputs.term_search))) + .child( + Button::new("term-search-prev") + .ghost() + .xsmall() + .icon(IconName::ChevronUp) + .tooltip(app.t("terminalSearchPrevious")), + ) + .child( + Button::new("term-search-next") + .ghost() + .xsmall() + .icon(IconName::ChevronDown) + .tooltip(app.t("terminalSearchNext")), + ) + .child( + Button::new("term-search-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.search_open = false; + } + cx.notify(); + }); + } + }), + ), + ) + }) + .into_any_element() +} + +fn key_to_bytes(ev: &KeyDownEvent) -> Option> { + let key = ev.keystroke.key.as_str(); + if ev.keystroke.modifiers.control && key == "c" { + return Some(vec![0x03]); + } + if ev.keystroke.modifiers.control && key == "d" { + return Some(vec![0x04]); + } + if ev.keystroke.modifiers.control && key == "l" { + return Some(vec![0x0c]); + } + match key { + "enter" => Some(b"\r".to_vec()), + "backspace" => Some(vec![0x7f]), + "tab" => Some(b"\t".to_vec()), + "escape" => Some(b"\x1b".to_vec()), + "up" => Some(b"\x1b[A".to_vec()), + "down" => Some(b"\x1b[B".to_vec()), + "right" => Some(b"\x1b[C".to_vec()), + "left" => Some(b"\x1b[D".to_vec()), + other if other.len() == 1 => Some(other.as_bytes().to_vec()), + _ => None, + } +} diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs new file mode 100644 index 00000000..11183d61 --- /dev/null +++ b/src-gpui/src/ui/workspace.rs @@ -0,0 +1,633 @@ +use gpui::{div, prelude::*, px, rgb, Context, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{Disableable, Selectable}; +use gpui_component::tab::Tab; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; + +use crate::app::AppView; +use crate::state::{DialogKind, SidebarMode, UnifiedTab}; +use crate::ui::{file_tree, file_viewer, git, notes, sidebar, terminal}; + +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("profile-workspace") + .size_full() + .min_w_0() + .min_h_0() + .child(topbar(app, window, cx)) + .child( + h_flex() + .id("workspace-body") + .flex_1() + .min_w_0() + .min_h_0() + .child(profile_sidebar(app, window, cx)) + .child(main_column(app, window, cx)), + ) +} + +fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(ws) = app.data.current_ws() else { + return div().id("empty-topbar").into_any_element(); + }; + let mode = ws.sidebar_mode; + let open = ws.sidebar_open; + let stats = ws.git_stats.clone(); + let name = ws.project_name.clone(); + let branch = if ws.branch.is_empty() { + "main".to_string() + } else { + ws.branch.clone() + }; + let worktree = ws.worktree.clone(); + let pr = ws.pr.clone(); + let collapsed = app.data.prefs.sidebar_collapsed; + let pad_left = if cfg!(target_os = "macos") && collapsed { + px(84.) + } else { + px(16.) + }; + let pad_right = if cfg!(target_os = "windows") { + px(118.) + } else { + px(16.) + }; + let controls = app.data.prefs.topbar_controls.clone(); + + div() + .id("project-topbar") + .w_full() + .min_h(px(44.)) + .pt(px(4.)) + .pb(px(6.)) + .pl(pad_left) + .pr(pad_right) + .border_b_1() + .border_color(theme.border) + .flex() + .items_end() + .justify_between() + .child( + h_flex() + .gap_2() + .when(collapsed, |el| { + el.child( + Button::new("expand-app-sidebar") + .ghost() + .small() + .icon(IconName::PanelLeftOpen) + .tooltip(app.t("expandSidebar")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.prefs.sidebar_collapsed = false; + app.persist_prefs(); + cx.notify(); + }); + } + }), + ) + }) + .child(mode_switch(app, mode, open, stats.files_changed, stats.insertions, stats.deletions, cx)), + ) + .child( + h_flex() + .absolute() + .left_0() + .right_0() + .justify_center() + .gap_2() + .child( + div() + .id("topbar-project-name") + .font_semibold() + .text_sm() + .on_click({ + let view = view.clone(); + let worktree = worktree.clone(); + move |_, _, cx| { + view.update(cx, |app, _| { + let _ = app.backend.reveal_path( + app.data.current_profile.as_deref().unwrap_or(""), + None, + ); + let _ = worktree; + }); + } + }) + .child(name), + ) + .child( + h_flex() + .id("topbar-branch") + .gap_1() + .text_color(theme.muted_foreground) + .hover(|el| el.text_color(theme.foreground)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws() { + app.data.overlay.branches = + app.backend.list_branches(&ws.worktree).unwrap_or_default(); + } + app.data.overlay.dialog = Some(DialogKind::SwitchBranch); + cx.notify(); + }); + } + }) + .child(Icon::new(IconName::GitHub).w(px(12.))) + .child(div().text_sm().child(branch)), + ), + ) + .child( + h_flex() + .gap_1() + .children(controls.into_iter().map(|id| { + topbar_control(app, &id, pr.as_ref(), cx) + })) + .child( + Button::new("project-settings") + .small() + .icon(IconName::Settings) + .tooltip(app.t("projectSettings")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = Some(DialogKind::ProjectSettings); + app.data.overlay.dialog_project = app.data.current_project.clone(); + if let Some(pid) = app.data.current_project.clone() { + if let Ok(cfg) = app.backend.project_config(&pid) { + app.inputs.worktree.update(cx, |s, cx| { + s.set_value( + cfg.worktree_dir.clone().unwrap_or_default(), + window, + cx, + ); + }); + app.inputs.init_script.update(cx, |s, cx| { + s.set_value(cfg.init_script.join("\n"), window, cx); + }); + app.inputs.setup_script.update(cx, |s, cx| { + s.set_value(cfg.setup_script.join("\n"), window, cx); + }); + app.inputs.teardown_script.update(cx, |s, cx| { + s.set_value(cfg.teardown_script.join("\n"), window, cx); + }); + } + } + cx.notify(); + }); + } + }), + ), + ) + .into_any_element() +} + +fn mode_switch( + app: &AppView, + mode: SidebarMode, + open: bool, + files: u32, + ins: u32, + del: u32, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + h_flex() + .id("sidebar-mode-switch") + .h(px(28.)) + .rounded_md() + .bg(theme.muted) + .p(px(2.)) + .gap_1() + .child(mode_btn("files", IconName::Folder, app.t("sidebarFilesTab"), open && mode == SidebarMode::Files, SidebarMode::Files, view.clone())) + .child( + h_flex() + .id("git-mode-btn") + .child(mode_btn("git", IconName::GitHub, app.t("sidebarGitTab"), open && mode == SidebarMode::Git, SidebarMode::Git, view.clone())) + .when(files > 0, |el| { + el.child( + h_flex() + .gap_1() + .pr_1() + .text_xs() + .child( + div() + .text_color(rgb(0x22c55e)) + .child(format!("+{ins}")), + ) + .child( + div() + .text_color(rgb(0xef4444)) + .child(format!("-{del}")), + ), + ) + }), + ) + .child(mode_btn("notes", IconName::BookOpen, app.t("notes"), open && mode == SidebarMode::Notes, SidebarMode::Notes, view)) +} + +fn mode_btn( + id: &'static str, + icon: IconName, + tip: String, + selected: bool, + mode: SidebarMode, + view: gpui::Entity, +) -> impl IntoElement { + Button::new(id) + .ghost() + .xsmall() + .icon(icon) + .selected(selected) + .tooltip(tip) + .on_click(move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + if ws.sidebar_open && ws.sidebar_mode == mode { + ws.sidebar_open = false; + } else { + ws.sidebar_mode = mode; + ws.sidebar_open = true; + } + app.data.prefs.profile_sidebar_open = ws.sidebar_open; + app.persist_prefs(); + } + cx.notify(); + }); + }) +} + +fn topbar_control( + app: &AppView, + id: &str, + pr: Option<&model::project::GitPullRequestStatus>, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + match id { + "github-desktop" => Button::new("tb-gh") + .ghost() + .small() + .icon(IconName::GitHub) + .tooltip(app.t("topbarGithubDesktop")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_topbar_app("github-desktop")); + } + }) + .into_any_element(), + "editor" => Button::new("tb-ed") + .ghost() + .small() + .icon(IconName::ALargeSmall) + .tooltip(app.t("topbarEditor")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_topbar_app("editor")); + } + }) + .into_any_element(), + "terminal" => Button::new("tb-term") + .ghost() + .small() + .icon(IconName::SquareTerminal) + .tooltip(app.t("topbarTerminal")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_topbar_app("terminal")); + } + }) + .into_any_element(), + "pr-status" => { + let (label, tip) = match pr { + None => (app.t("topbarPrNoPr"), app.t("topbarPrNoPrTooltip")), + Some(p) if p.is_draft => ( + app.t("topbarPrDraft"), + crate::i18n::tf( + app.data.locale, + "topbarPrTooltip", + &[ + ("number", &p.number.to_string()), + ("title", &p.title), + ("state", "draft"), + ], + ), + ), + Some(p) => ( + match p.state.as_str() { + "MERGED" => app.t("topbarPrMerged"), + "CLOSED" => app.t("topbarPrClosed"), + _ => app.t("topbarPrOpen"), + }, + crate::i18n::tf( + app.data.locale, + "topbarPrTooltip", + &[ + ("number", &p.number.to_string()), + ("title", &p.title), + ("state", &p.state), + ], + ), + ), + }; + Button::new("tb-pr") + .ghost() + .small() + .icon(IconName::GitHub) + .label(label) + .tooltip(tip) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_pr()); + } + }) + .into_any_element() + } + _ => div().into_any_element(), + } +} + +fn profile_sidebar( + app: &mut AppView, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let Some(ws) = app.data.current_ws() else { + return div().id("no-profile-sidebar").into_any_element(); + }; + if !ws.sidebar_open { + return div().id("profile-sidebar-closed").into_any_element(); + } + let width = app.data.prefs.profile_sidebar_width.clamp(180.0, 560.0); + let mode = ws.sidebar_mode; + let theme = cx.theme().clone(); + + div() + .id("profile-sidebar") + .w(px(width)) + .h_full() + .border_r_1() + .border_color(theme.border) + .min_w_0() + .child( + // Keep all three mounted; hide inactive ones. + v_flex() + .size_full() + .child(visible(mode == SidebarMode::Files, file_tree::render(app, window, cx))) + .child(visible(mode == SidebarMode::Git, git::render_panel(app, window, cx))) + .child(visible(mode == SidebarMode::Notes, notes::render(app, window, cx))), + ) + .into_any_element() +} + +fn visible(show: bool, child: impl IntoElement) -> impl IntoElement { + div() + .size_full() + .when(!show, |el| el.h(px(0.)).overflow_hidden().invisible()) + .when(show, |el| el.flex_1()) + .child(child) +} + +fn main_column( + app: &mut AppView, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let Some(ws) = app.data.current_ws() else { + return div().id("no-main").flex_1().into_any_element(); + }; + if !ws.has_tabs() { + return empty_cta(app, cx).into_any_element(); + } + v_flex() + .id("workspace-main") + .flex_1() + .min_w_0() + .min_h_0() + .child(tab_bar(app, cx)) + .child(tab_bodies(app, window, cx)) + .into_any_element() +} + +fn empty_cta(app: &AppView, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + v_flex() + .id("empty-terminals") + .flex_1() + .items_center() + .justify_center() + .gap_3() + .child(Icon::new(IconName::SquareTerminal).w(px(28.))) + .child(div().font_semibold().child(app.t("noTerminalsOpen"))) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noTerminalsOpenDescription")), + ) + .child(new_terminal_control(app, view)) +} + +fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let Some(ws) = app.data.current_ws() else { + return div().id("no-tabs").into_any_element(); + }; + let active = ws.active; + + h_flex() + .id("unified-tabs") + .w_full() + .border_b_1() + .border_color(theme.border) + .overflow_x_scroll() + .children(ws.terminals.iter().enumerate().map(|(ix, term)| { + let selected = matches!(active, Some(UnifiedTab::Terminal { index }) if index == ix); + let id = term.id.clone(); + let profile = term.profile_id.clone(); + Tab::new() + .label(term.title.clone()) + .icon(IconName::SquareTerminal) + .selected(selected) + .suffix( + h_flex() + .gap_1() + .child(sidebar::agent_dot(term.agent)) + .child( + Button::new(crate::ui::eid(format!("close-term-{id}"))) + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click({ + let view = view.clone(); + let id = id.clone(); + let profile = profile.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.close_terminal(&profile, &id); + cx.notify(); + }); + } + }), + ), + ) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.active = Some(UnifiedTab::Terminal { index: ix }); + } + cx.notify(); + }); + } + }) + })) + .children(ws.files.iter().enumerate().map(|(ix, file)| { + let selected = matches!(active, Some(UnifiedTab::File { index }) if index == ix); + let path = file.path.clone(); + let profile = ws.profile_id.clone(); + Tab::new() + .label(file.title.clone()) + .icon(IconName::File) + .selected(selected) + .suffix( + h_flex() + .gap_1() + .when(file.dirty(), |el| { + el.child(div().size(px(8.)).rounded_full().bg(theme.muted_foreground)) + }) + .child( + Button::new(crate::ui::eid(format!("close-file-{path}"))) + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click({ + let view = view.clone(); + let path = path.clone(); + let profile = profile.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.request_close_file(&profile, &path); + cx.notify(); + }); + } + }), + ), + ) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.active = Some(UnifiedTab::File { index: ix }); + } + if let Some(file) = app.data.current_ws().and_then(|w| w.files.get(ix)) { + if !file.preview { + let draft = file.draft.clone(); + app.inputs.file_editor.update(cx, |s, cx| { + s.set_value(draft, window, cx); + }); + } + } + cx.notify(); + }); + } + }) + })) + .child(new_terminal_control(app, view)) + .into_any_element() +} + +fn new_terminal_control(app: &AppView, view: gpui::Entity) -> impl IntoElement { + let has_templates = !app.data.prefs.templates.is_empty() + || app + .data + .current_ws() + .map(|w| !w.config.terminal_templates.is_empty()) + .unwrap_or(false); + if has_templates { + Button::new("new-term-split") + .ghost() + .small() + .icon(IconName::Plus) + .label(app.t("newTerminal")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.context_menu = Some(( + crate::state::ContextMenu::NewTerminal, + 200.0, + 80.0, + )); + cx.notify(); + }); + } + }) + .into_any_element() + } else { + Button::new("new-term") + .ghost() + .small() + .icon(IconName::Plus) + .label(app.t("newTerminal")) + .on_click(move |_, _, cx| { + view.update(cx, |app, cx| { + app.create_terminal(&app.t("newTerminal"), "", Vec::new()); + cx.notify(); + }); + }) + .into_any_element() + } +} + +fn tab_bodies( + app: &mut AppView, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let Some(ws) = app.data.current_ws() else { + return div().into_any_element(); + }; + let active = ws.active; + let term_count = ws.terminals.len(); + let file_active = matches!(active, Some(UnifiedTab::File { .. })); + + div() + .id("tab-bodies") + .flex_1() + .min_h_0() + .min_w_0() + .relative() + .child( + div() + .id("file-viewer-slot") + .size_full() + .when(!file_active, |el| el.invisible().h(px(0.))) + .child(file_viewer::render(app, window, cx)), + ) + .children((0..term_count).map(|ix| { + let show = matches!(active, Some(UnifiedTab::Terminal { index }) if index == ix); + div() + .id(crate::ui::eid(format!("term-slot-{ix}"))) + .size_full() + .when(!show, |el| el.invisible()) + .child(terminal::render(app, ix, window, cx)) + })) + .into_any_element() +} diff --git a/src-tauri/crates/model/src/profile.rs b/src-tauri/crates/model/src/profile.rs index 7c9f943f..180795f6 100644 --- a/src-tauri/crates/model/src/profile.rs +++ b/src-tauri/crates/model/src/profile.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::project::GitDiffStats; -#[derive(Queryable, Selectable, Serialize)] +#[derive(Queryable, Selectable, Serialize, Clone, Debug)] #[diesel(table_name = profiles)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Profile { diff --git a/src-tauri/crates/model/src/project.rs b/src-tauri/crates/model/src/project.rs index 5187bfd2..ef3c692c 100644 --- a/src-tauri/crates/model/src/project.rs +++ b/src-tauri/crates/model/src/project.rs @@ -27,7 +27,7 @@ pub struct ProjectTerminalTemplate { pub commands: Vec, } -#[derive(Queryable, Selectable, Serialize)] +#[derive(Queryable, Selectable, Serialize, Clone, Debug)] #[diesel(table_name = projects)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct Project { @@ -58,7 +58,7 @@ pub struct UpdateProject { pub folder: Option, } -#[derive(Serialize)] +#[derive(Serialize, Clone, Debug)] pub struct ProjectWithProfiles { pub id: String, pub name: String, diff --git a/src-tauri/crates/model/src/project_group.rs b/src-tauri/crates/model/src/project_group.rs index 2ac3f5c1..65bfdf6b 100644 --- a/src-tauri/crates/model/src/project_group.rs +++ b/src-tauri/crates/model/src/project_group.rs @@ -2,7 +2,7 @@ use crate::schema::project_groups; use diesel::prelude::*; use serde::Serialize; -#[derive(Queryable, Selectable, Serialize)] +#[derive(Queryable, Selectable, Serialize, Clone, Debug)] #[diesel(table_name = project_groups)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct ProjectGroup { From 8112e146f8467ace5a09af6aa6be7ffe366a4f5c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:34:49 +0000 Subject: [PATCH 03/50] Implement GPUI terminal grid, updater, and sidebar fidelity. Render vt100 cells with ANSI colors and search cycling, check GitHub releases on startup and from About, persist sidebar resize/reorder, preview images, and autosave notes. Document GPUI as the primary desktop shell while the Tauri UI remains as reference. Co-authored-by: AkaraChen --- README.md | 52 ++++----- docs/README.md | 4 +- justfile | 13 ++- src-gpui/Cargo.lock | 38 ++++++ src-gpui/Cargo.toml | 1 + src-gpui/README.md | 2 +- src-gpui/src/app.rs | 175 +++++++++++++++++++++++++++- src-gpui/src/backend.rs | 91 +++++++++++++++ src-gpui/src/main.rs | 1 + src-gpui/src/state.rs | 77 ++++++++++++- src-gpui/src/ui/file_viewer.rs | 138 ++++++++++++++++------ src-gpui/src/ui/notes.rs | 9 -- src-gpui/src/ui/settings.rs | 126 +++++++++++++++++--- src-gpui/src/ui/shell.rs | 75 ++++++++++-- src-gpui/src/ui/sidebar.rs | 104 ++++++++++++++++- src-gpui/src/ui/terminal.rs | 205 +++++++++++++++++++++++++++++---- src-gpui/src/ui/workspace.rs | 53 +++++++-- src-gpui/src/updater.rs | 153 ++++++++++++++++++++++++ 18 files changed, 1177 insertions(+), 140 deletions(-) create mode 100644 src-gpui/src/updater.rs diff --git a/README.md b/README.md index c66f99dc..b4b0da9a 100644 --- a/README.md +++ b/README.md @@ -39,67 +39,63 @@ AI-assisted development creates more parallel state than a normal coding session ## Tech Stack -- **Desktop shell**: Tauri 2 -- **Frontend**: React 19, TypeScript, Vite -- **UI state**: Zustand + Immer -- **Server state**: TanStack Query -- **Backend**: Rust workspace +- **Desktop shell**: GPUI + gpui-component (`src-gpui/`) — this is the current product UI +- **Domain backend**: Rust workspace crates in `src-tauri/crates/` (model, repo, service, infra) - **Database**: SQLite via Diesel migrations -- **Terminal runtime**: integrated PTY service -- **Package manager**: Bun +- **Terminal runtime**: integrated PTY service + vt100 +- **Legacy reference shell**: Tauri 2 + React 19 (kept in `src/` / `src-tauri/` while the GPUI rewrite lands) ## Getting Started ### Prerequisites -- macOS -- Bun -- Rust stable toolchain -- Tauri 2 development prerequisites +- Rust 1.87+ (GPUI) - `just` for development helper commands -- `fama` on `PATH` for `just fmt` TypeScript formatting - -### Install dependencies - -```bash -bun install -``` +- On Linux: `libxkbcommon`, Vulkan, and Wayland/X11 development libraries +- The previous Tauri + React shell also needs Bun and Tauri 2 if you still run that path ### Run the desktop app ```bash -bun tauri dev +just gpui +# or +cd src-gpui && cargo run ``` -### Run only the frontend +The GPUI app uses the same SQLite database as the old Tauri shell: `$XDG_DATA_HOME/com.akrc.code/app.db` (or the platform equivalent). Preferences live in `gpui-prefs.json` next to the DB. + +### Legacy Tauri + React shell ```bash -bun run dev +bun install +bun tauri dev ``` ### Build ```bash -bun tauri build +cd src-gpui && cargo build --release ``` +The legacy Tauri bundle is still `bun tauri build`. + ## Useful Commands | Command | Description | | --- | --- | -| `bun tauri dev` | Run the full desktop app with frontend and Rust hot reload | -| `bun run dev` | Run the Vite frontend only | -| `bun tauri build` | Build the production desktop app | -| `cd src-tauri && cargo test` | Run Rust tests | -| `cargo tauri-typegen generate` | Regenerate frontend IPC bindings after Rust command changes | +| `just gpui` | Run the GPUI desktop app | +| `just gpui-check` | Type-check the GPUI crate | +| `cd src-tauri && cargo test` | Run Rust domain tests | | `just fmt` | Format TypeScript and Rust | | `just coverage` | Generate Rust coverage report | +| `bun tauri dev` | Run the legacy Tauri + React shell | ## Project Structure ```text 2code/ -├── src/ # React + Vite frontend +├── src-gpui/ # GPUI native desktop shell (primary) +├── src/ # Legacy React + Vite frontend │ ├── features/ # Feature-first app modules │ ├── shared/ # Shared lib, providers, components, hooks │ ├── layout/ # App shell and sidebar diff --git a/docs/README.md b/docs/README.md index d81420a6..8112b387 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,10 +1,10 @@ # 2code Architecture Documentation -> Auto-generated structural documentation for the **2code** Tauri 2 desktop application. +> Structural documentation for **2code**. The current product UI is the GPUI native shell in `src-gpui/`. The Tauri + React tree remains as a reference implementation. ## Overview -**2code** is a macOS desktop app for managing code projects with integrated persistent terminal sessions. It pairs a React 19 frontend with a Rust backend, connected via Tauri 2 IPC with auto-generated TypeScript bindings. +**2code** is a desktop app for managing code projects with integrated persistent terminal sessions. The GPUI rewrite in `src-gpui/` owns the windows and UI; it reuses the existing Rust domain crates (`model`, `repo`, `service`, `infra`) without Tauri IPC. ### Tech Stack diff --git a/justfile b/justfile index e7afb777..d71b1486 100644 --- a/justfile +++ b/justfile @@ -1,3 +1,10 @@ +# GPUI native desktop app (primary product UI) +gpui: + cd src-gpui && cargo run + +gpui-check: + cd src-gpui && cargo check + start: ./start.sh @@ -6,12 +13,6 @@ fmt: cd src-tauri && cargo fmt cd src-gpui && cargo fmt -gpui: - cd src-gpui && cargo run - -gpui-check: - cd src-gpui && cargo check - test-frontend: bun run test diff --git a/src-gpui/Cargo.lock b/src-gpui/Cargo.lock index f70c9867..151ce68b 100644 --- a/src-gpui/Cargo.lock +++ b/src-gpui/Cargo.lock @@ -963,6 +963,7 @@ dependencies = [ "service", "tracing", "tracing-subscriber", + "ureq", "uuid", "vt100", ] @@ -5289,6 +5290,7 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -6894,6 +6896,24 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -7296,6 +7316,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/src-gpui/Cargo.toml b/src-gpui/Cargo.toml index 9b32eec3..9f65b1ec 100644 --- a/src-gpui/Cargo.toml +++ b/src-gpui/Cargo.toml @@ -28,6 +28,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } vt100 = "0.16" uuid = { version = "1", features = ["v4"] } open = "5" +ureq = { version = "2", features = ["json"] } [target.'cfg(target_os = "windows")'.dependencies] libsqlite3-sys = { version = "0.37", features = ["bundled"] } diff --git a/src-gpui/README.md b/src-gpui/README.md index 8b72a05c..b9128c0f 100644 --- a/src-gpui/README.md +++ b/src-gpui/README.md @@ -1,6 +1,6 @@ # 2code GPUI -Native desktop rewrite of 2code using [GPUI](https://gpui.rs) and [gpui-component](https://github.com/longbridge/gpui-component). The React/Tauri UI is still in the repo; this crate is the new primary shell and calls the existing `model` / `repo` / `service` / `infra` crates directly (no IPC). +Native desktop rewrite of 2code using [GPUI](https://gpui.rs) and [gpui-component](https://github.com/longbridge/gpui-component). This crate is the primary product shell. It calls the existing `model` / `repo` / `service` / `infra` crates directly (no IPC). The React/Tauri UI remains in the repo as a reference implementation. ## Run diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index da6956a5..5c270365 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -79,6 +79,7 @@ pub struct AppView { pub inputs: Inputs, pub focus: FocusHandle, pub settings_window: Option>, + pub settings_view: Option>, } impl AppView { @@ -114,6 +115,7 @@ impl AppView { toasts: Vec::new(), toast_seq: 0, sidebar_error: None, + notes_dirty_since: None, }; let inputs = Inputs { @@ -188,6 +190,7 @@ impl AppView { inputs, focus: cx.focus_handle(), settings_window: None, + settings_view: None, }; cx.spawn(async move |this, cx| loop { @@ -204,6 +207,19 @@ impl AppView { }) .detach(); + cx.spawn(async move |this, cx| { + Timer::after(Duration::from_secs(1)).await; + let accept_beta = this + .update(cx, |app, _| app.data.prefs.accept_beta) + .unwrap_or(false); + let result = crate::updater::check_for_update(accept_beta); + let _ = this.update(cx, |app, cx| { + app.apply_update_result(result, true); + cx.notify(); + }); + }) + .detach(); + let mut view = view; view.reload_projects(); if view.data.projects.is_empty() { @@ -240,6 +256,14 @@ impl AppView { ThemePref::System => cx.window_appearance().into(), }; gpui_component::Theme::change(mode, Some(window), cx); + let scale = self.data.prefs.radius.scale(); + let theme = gpui_component::Theme::global_mut(cx); + theme.radius = px(6.0 * scale); + theme.radius_lg = px(8.0 * scale); + if !self.data.prefs.font_family.is_empty() { + theme.mono_font_family = self.data.prefs.font_family.clone().into(); + theme.mono_font_size = px(self.data.prefs.font_size); + } } pub fn reload_projects(&mut self) { @@ -429,6 +453,17 @@ impl AppView { } else { None }; + let preview_path = preview + .as_ref() + .map(|p| p.file_path.clone()) + .unwrap_or_default(); + let archive_entries = preview + .as_ref() + .and_then(|p| p.archive_entries.clone()) + .unwrap_or_default() + .into_iter() + .map(|e| (e.path, e.kind)) + .collect(); let Some(ws) = self.data.workspaces.get_mut(profile_id) else { return; }; @@ -452,6 +487,8 @@ impl AppView { .map(|p| p.kind.clone()) .unwrap_or_default(), binary_note: preview.map(|p| p.mime_type).unwrap_or_default(), + preview_path, + archive_entries, }; ws.files.push(tab); ws.active = Some(UnifiedTab::File { @@ -597,7 +634,7 @@ impl AppView { } } - pub fn tick(&mut self, _cx: &mut Context) { + pub fn tick(&mut self, cx: &mut Context) { self.data.expire_toasts(); let ids: Vec<(String, String)> = self .data @@ -627,6 +664,141 @@ impl AppView { if let Ok(exits) = self.backend.exits.lock() { let _ = exits.len(); } + self.autosave_notes(cx); + } + + fn autosave_notes(&mut self, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let live = self.inputs.notes.read(cx).value().to_string(); + let Some(ws) = self.data.workspaces.get_mut(&profile_id) else { + return; + }; + if live == ws.notes { + return; + } + ws.notes_status = NotesStatus::Saving; + match self.data.notes_dirty_since { + None => self.data.notes_dirty_since = Some(std::time::Instant::now()), + Some(since) if since.elapsed() < Duration::from_millis(800) => {} + Some(_) => { + self.data.notes_dirty_since = None; + self.save_notes(cx); + } + } + } + + pub fn cycle_term_search(&mut self, cx: &mut Context, next: bool) { + let query = self.inputs.term_search.read(cx).value().to_string(); + if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.search_query = query.clone(); + let hits = term.search_hits(&query); + if hits.is_empty() { + term.search_ix = 0; + return; + } + if next { + term.search_ix = (term.search_ix + 1) % hits.len(); + } else { + term.search_ix = if term.search_ix == 0 { + hits.len() - 1 + } else { + term.search_ix - 1 + }; + } + } + } + + pub fn sync_pty_size(&mut self, window: &Window) { + let (rows, cols) = self.estimate_pty_size(window); + let mut resized = Vec::new(); + if let Some(ws) = self.data.current_ws_mut() { + for term in &mut ws.terminals { + if term.set_size(rows, cols) { + resized.push(term.id.clone()); + } + } + } + for id in resized { + let _ = self.backend.resize_pty(&id, rows, cols); + } + } + + fn estimate_pty_size(&self, window: &Window) -> (u16, u16) { + let size = window.viewport_size(); + let mut chrome = 48.0; + if !self.data.prefs.sidebar_collapsed { + chrome += self.data.prefs.sidebar_width; + } + if self.data.current_ws().map(|w| w.sidebar_open).unwrap_or(false) { + chrome += self.data.prefs.profile_sidebar_width; + } + let font = self.data.prefs.font_size.max(10.0); + let cols = ((f32::from(size.width) - chrome - 24.0) / (font * 0.62)).floor() as i32; + let rows = ((f32::from(size.height) - 96.0) / (font * 1.35)).floor() as i32; + (rows.clamp(10, 120) as u16, cols.clamp(40, 300) as u16) + } + + pub fn apply_update_result(&mut self, result: Result, silent: bool) { + match result { + Ok(info) if info.available => { + let title = self.tf( + "updateAvailableTitle", + &[("version", &info.latest_version)], + ); + let body = self.tf( + "updateAvailableDescription", + &[ + ("currentVersion", &info.current_version), + ("version", &info.latest_version), + ], + ); + self.data.push_toast_action( + ToastKind::Info, + title, + body, + Some(crate::state::ToastAction::OpenAbout), + ); + } + Ok(_) => { + if !silent { + self.data.push_toast( + ToastKind::Info, + self.t("updateNotAvailableTitle"), + self.t("updateNotAvailableDescription"), + ); + } + } + Err(err) => { + if !silent { + self.data.push_toast( + ToastKind::Error, + self.t("updateCheckFailedTitle"), + err, + ); + } + } + } + self.data.overlay.update_checked = true; + } + + pub fn move_sidebar_project(&mut self, id: &str, delta: i32) { + if let Err(err) = self.backend.move_project(id, delta) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + self.reload_projects(); + } + + pub fn drop_sidebar_project(&mut self, dragged: &str, target: Option<&str>, unpin: bool) { + if let Err(err) = self.backend.drop_project(dragged, target, unpin) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + self.reload_projects(); } pub fn create_project_from_dialog(&mut self, window: &mut Window, cx: &mut Context) { @@ -1238,6 +1410,7 @@ pub fn extract_file_hunk(diff: &str, path: &str) -> String { impl gpui::Render for AppView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.apply_theme(window, cx); + self.sync_pty_size(window); if self.data.overlay.palette_open { let q = self.inputs.palette.read(cx).value().to_string(); if self.data.overlay.palette_results.is_empty() && !q.is_empty() { diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index 73f7ad24..f0c9d889 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -391,6 +391,88 @@ impl Backend { }]) } + pub fn move_project(&self, project_id: &str, delta: i32) -> Result<(), AppError> { + let projects = self.list_projects()?; + let Some(project) = projects.iter().find(|p| p.id == project_id).cloned() else { + return Ok(()); + }; + let mut bucket: Vec<_> = projects + .into_iter() + .filter(|p| { + if project.pinned_at.is_some() { + p.pinned_at.is_some() + } else { + p.pinned_at.is_none() && p.group_id == project.group_id + } + }) + .collect(); + if project.pinned_at.is_some() { + bucket.sort_by_key(|p| p.pinned_order.unwrap_or(0)); + } else { + bucket.sort_by_key(|p| p.sort_order); + } + let Some(ix) = bucket.iter().position(|p| p.id == project_id) else { + return Ok(()); + }; + let next = (ix as i32 + delta).clamp(0, bucket.len() as i32 - 1) as usize; + if next == ix { + return Ok(()); + } + bucket.swap(ix, next); + let updates = bucket + .iter() + .enumerate() + .map(|(i, p)| ProjectSidebarLayoutUpdate { + kind: "project".into(), + id: p.id.clone(), + group_id: p.group_id.clone(), + sort_order: Some(if p.pinned_at.is_some() { + p.sort_order + } else { + i as i32 + }), + pinned_order: if p.pinned_at.is_some() { + Some(i as i32) + } else { + None + }, + }) + .collect(); + self.update_sidebar_layout(updates) + } + + pub fn drop_project( + &self, + dragged: &str, + target: Option<&str>, + unpin: bool, + ) -> Result<(), AppError> { + let project = self.find_project(dragged)?; + if unpin { + return self.update_sidebar_layout(vec![ProjectSidebarLayoutUpdate { + kind: "project".into(), + id: project.id, + group_id: None, + sort_order: Some(project.sort_order), + pinned_order: None, + }]); + } + let Some(target) = target else { + return Ok(()); + }; + if target == dragged { + return Ok(()); + } + let dest = self.find_project(target)?; + self.update_sidebar_layout(vec![ProjectSidebarLayoutUpdate { + kind: "project".into(), + id: project.id, + group_id: dest.group_id, + sort_order: Some(dest.sort_order.saturating_sub(1)), + pinned_order: dest.pinned_order, + }]) + } + pub fn list_sessions(&self, project_id: &str) -> Result, AppError> { self.with_db(|conn| service::pty::list_project_sessions(conn, project_id)) } @@ -453,6 +535,15 @@ pub fn is_previewable(path: &str) -> bool { .any(|ext| lower.ends_with(ext)) } +pub fn is_image(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + [ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".ico", ".tif", ".tiff", + ] + .iter() + .any(|ext| lower.ends_with(ext)) +} + pub fn is_markdown(path: &str) -> bool { let lower = path.to_ascii_lowercase(); lower.ends_with(".md") || lower.ends_with(".mdx") diff --git a/src-gpui/src/main.rs b/src-gpui/src/main.rs index 89addfca..121d18aa 100644 --- a/src-gpui/src/main.rs +++ b/src-gpui/src/main.rs @@ -4,6 +4,7 @@ mod i18n; mod prefs; mod state; mod ui; +mod updater; use gpui::{ point, prelude::*, px, size, Application, Bounds, TitlebarOptions, WindowBounds, WindowOptions, diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index d635cb98..02982fee 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -58,6 +58,20 @@ pub enum AgentStatus { Completed, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AgentKind { + #[default] + Unknown, + Claude, + Codex, + Gemini, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastAction { + OpenAbout, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DialogKind { CreateProject, @@ -91,7 +105,7 @@ pub struct Toast { pub kind: ToastKind, pub title: String, pub body: String, - pub action: Option, + pub action: Option, pub created: Instant, } @@ -112,6 +126,8 @@ pub struct OpenFileTab { pub preview: bool, pub preview_kind: String, pub binary_note: String, + pub preview_path: String, + pub archive_entries: Vec<(String, String)>, } impl OpenFileTab { @@ -133,7 +149,9 @@ pub struct TermSession { pub parser: vt100::Parser, pub search_open: bool, pub search_ix: usize, + pub search_query: String, pub agent: AgentStatus, + pub agent_kind: AgentKind, pub completed_hidden: bool, pub cols: u16, pub rows: u16, @@ -148,7 +166,9 @@ impl TermSession { parser: vt100::Parser::new(32, 120, 10_000), search_open: false, search_ix: 0, + search_query: String::new(), agent: AgentStatus::Idle, + agent_kind: AgentKind::Unknown, completed_hidden: false, cols: 120, rows: 32, @@ -160,9 +180,28 @@ impl TermSession { self.detect_agent(); } + pub fn set_size(&mut self, rows: u16, cols: u16) -> bool { + if self.rows == rows && self.cols == cols { + return false; + } + self.rows = rows; + self.cols = cols; + self.parser.screen_mut().set_size(rows, cols); + true + } + fn detect_agent(&mut self) { let text = self.parser.screen().contents(); - let lower = text.to_ascii_lowercase(); + let lower = format!("{} {}", self.title, text).to_ascii_lowercase(); + self.agent_kind = if lower.contains("claude") { + AgentKind::Claude + } else if lower.contains("codex") { + AgentKind::Codex + } else if lower.contains("gemini") { + AgentKind::Gemini + } else { + self.agent_kind + }; if lower.contains("waiting for") || lower.contains("waiting on") { self.agent = AgentStatus::Waiting; } else if lower.contains("running") || lower.contains("thinking") { @@ -176,6 +215,23 @@ impl TermSession { pub fn screen_text(&self) -> String { self.parser.screen().contents() } + + pub fn search_hits(&self, query: &str) -> Vec<(u16, usize, usize)> { + if query.is_empty() { + return Vec::new(); + } + let needle = query.to_ascii_lowercase(); + self.screen_text() + .lines() + .enumerate() + .flat_map(|(row, line)| { + let hay = line.to_ascii_lowercase(); + hay.match_indices(&needle) + .map(|(col, _)| (row as u16, col, needle.len())) + .collect::>() + }) + .collect() + } } #[derive(Debug, Clone)] @@ -278,6 +334,10 @@ pub struct OverlayState { pub pending_close_file: Option, pub editing_template: Option, pub project_settings_tab: usize, + pub sidebar_drag: Option<(f32, f32)>, + pub profile_sidebar_drag: Option<(f32, f32)>, + pub drag_project: Option, + pub update_checked: bool, } pub struct AppData { @@ -293,6 +353,7 @@ pub struct AppData { pub toast_seq: u64, pub sidebar_error: Option, pub locale: Locale, + pub notes_dirty_since: Option, } impl AppData { @@ -325,13 +386,23 @@ impl AppData { } pub fn push_toast(&mut self, kind: ToastKind, title: impl Into, body: impl Into) { + self.push_toast_action(kind, title, body, None); + } + + pub fn push_toast_action( + &mut self, + kind: ToastKind, + title: impl Into, + body: impl Into, + action: Option, + ) { self.toast_seq += 1; self.toasts.push(Toast { id: self.toast_seq, kind, title: title.into(), body: body.into(), - action: None, + action, created: Instant::now(), }); } diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index fd6ff9d8..7e9a3185 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -1,4 +1,6 @@ -use gpui::{div, prelude::*, px, Context, Window}; +use std::path::PathBuf; + +use gpui::{div, img, prelude::*, px, rgb, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; use gpui_component::text::TextView; @@ -22,38 +24,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }; if file.preview { - return v_flex() - .id("binary-preview") - .size_full() - .child( - h_flex() - .w_full() - .min_h(px(36.)) - .px_3() - .bg(theme.muted) - .justify_between() - .child(div().text_sm().child(file.title.clone())) - .child(div().text_xs().child("Preview")), - ) - .child( - v_flex() - .flex_1() - .items_center() - .justify_center() - .gap_2() - .child(div().text_color(theme.muted_foreground).child(file.preview_kind.clone())) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(if file.binary_note.is_empty() { - "Preview unavailable".to_string() - } else { - file.binary_note - }), - ), - ) - .into_any_element(); + return preview_pane(app, &file, cx).into_any_element(); } if backend::is_markdown(&file.path) { @@ -149,6 +120,107 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .into_any_element() } +fn preview_pane( + _app: &AppView, + file: &crate::state::OpenFileTab, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let path = file.path.clone(); + v_flex() + .id("binary-preview") + .size_full() + .child( + h_flex() + .w_full() + .min_h(px(36.)) + .px_3() + .bg(theme.muted) + .justify_between() + .child(div().text_sm().child(file.title.clone())) + .child( + h_flex() + .gap_2() + .child(div().text_xs().child(file.preview_kind.clone())) + .child( + Button::new("open-external") + .xsmall() + .label("Open") + .on_click({ + let view = view.clone(); + let path = path.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_external(&path)); + } + }), + ), + ), + ) + .child(preview_body(file, cx)) +} + +fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme().clone(); + if file.preview_kind == "image" || backend::is_image(&file.path) { + let src = if file.preview_path.is_empty() { + file.path.clone() + } else { + file.preview_path.clone() + }; + return div() + .id("image-preview") + .size_full() + .flex() + .items_center() + .justify_center() + .bg(rgb(0x1b1f23)) + .child( + img(PathBuf::from(src)) + .id("image-preview-img") + .max_w_full() + .max_h_full(), + ) + .into_any_element(); + } + if file.preview_kind == "archive" && !file.archive_entries.is_empty() { + return v_flex() + .id("archive-preview") + .size_full() + .p_3() + .gap_1() + .overflow_y_scroll() + .children(file.archive_entries.iter().map(|(path, kind)| { + h_flex() + .gap_2() + .child(div().text_xs().text_color(theme.muted_foreground).child(kind.clone())) + .child(div().text_sm().child(path.clone())) + })) + .into_any_element(); + } + v_flex() + .flex_1() + .items_center() + .justify_center() + .gap_2() + .child( + div() + .text_color(theme.muted_foreground) + .child(file.preview_kind.clone()), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(if file.binary_note.is_empty() { + "Preview unavailable".to_string() + } else { + file.binary_note.clone() + }), + ) + .into_any_element() +} + fn md_btn(id: &'static str, label: &'static str, wrap: &'static str, view: &gpui::Entity) -> impl IntoElement { let view = view.clone(); Button::new(id) diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs index 2305eced..3b7cdb08 100644 --- a/src-gpui/src/ui/notes.rs +++ b/src-gpui/src/ui/notes.rs @@ -7,7 +7,6 @@ use crate::state::NotesStatus; pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); - let view = cx.entity(); let status = app .data .current_ws() @@ -44,14 +43,6 @@ pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context .flex_1() .min_h_0() .p_2() - .on_mouse_up(gpui::MouseButton::Left, { - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.save_notes(cx); - }); - } - }) .child(Input::new(&app.inputs.notes)), ) } diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 87faa9aa..ca61a1b2 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -16,11 +16,33 @@ use crate::i18n::Locale; use crate::prefs::{term_theme_by_name, RadiusPref, TERM_THEMES, ThemePref}; use crate::state::SettingsTab; +pub fn open_update_page(app: &mut AppView, window: &mut Window, cx: &mut Context) { + open_settings_at(app, window, cx, Some(SettingsTab::About)); +} + pub fn open_settings_window(app: &mut AppView, window: &mut Window, cx: &mut Context) { - if let Some(handle) = app.settings_window.clone() { - if handle.update(cx, |_, win, _| win.activate_window()).is_ok() { - app.data.overlay.settings_open = true; - return; + open_settings_at(app, window, cx, None); +} + +fn open_settings_at( + app: &mut AppView, + window: &mut Window, + cx: &mut Context, + tab: Option, +) { + if let Some(existing) = app.settings_view.as_ref().and_then(|w| w.upgrade()) { + if let Some(tab) = tab { + existing.update(cx, |this, cx| { + this.tab = tab; + cx.notify(); + }); + } + if let Some(handle) = app.settings_window.clone() { + if handle.update(cx, |_, win, _| win.activate_window()).is_ok() { + app.data.overlay.settings_open = true; + app.data.overlay.settings_tab = tab.unwrap_or(app.data.overlay.settings_tab); + return; + } } } let backend = app.backend.clone(); @@ -40,7 +62,19 @@ pub fn open_settings_window(app: &mut AppView, window: &mut Window, cx: &mut Con ..Default::default() }, move |window, cx| { - let view = cx.new(|cx| SettingsView::new(backend.clone(), prefs.clone(), locale, main.clone(), window, cx)); + let initial = tab.unwrap_or(SettingsTab::General); + let view = cx.new(|cx| { + let mut settings = + SettingsView::new(backend.clone(), prefs.clone(), locale, main.clone(), window, cx); + settings.tab = initial; + if let Some(main) = settings.main.upgrade() { + let weak = cx.entity().downgrade(); + main.update(cx, |app, _| { + app.settings_view = Some(weak); + }); + } + settings + }); cx.new(|cx| Root::new(view, window, cx)) }, ) { @@ -63,6 +97,8 @@ pub struct SettingsView { template_cwd: gpui::Entity, template_cmds: gpui::Entity, update_status: String, + latest_version: Option, + latest_url: String, } impl SettingsView { @@ -112,6 +148,8 @@ impl SettingsView { template_cwd: inp(window, cx, "", "", false), template_cmds: inp(window, cx, "", "", true), update_status: crate::i18n::t(locale, "updateIdleDescription"), + latest_version: None, + latest_url: crate::updater::releases_page().to_string(), } } @@ -124,6 +162,10 @@ impl SettingsView { self.prefs.worktree_dir = self.worktree.read(cx).value().to_string(); self.prefs.language = self.locale; self.prefs.save(&self.backend.app_data_dir); + let scale = self.prefs.radius.scale(); + let theme = gpui_component::Theme::global_mut(cx); + theme.radius = px(6.0 * scale); + theme.radius_lg = px(8.0 * scale); if let Some(main) = self.main.upgrade() { let prefs = self.prefs.clone(); let locale = self.locale; @@ -648,17 +690,69 @@ impl SettingsView { ) .child(div().text_sm().child(self.update_status.clone())) .child( - Button::new("check-upd") - .label(self.t("checkForUpdates")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |this, cx| { - this.update_status = this.t("updateNotAvailableDescription"); - cx.notify(); - }); - } - }), + h_flex() + .gap_2() + .child( + Button::new("check-upd") + .label(self.t("checkForUpdates")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.update_status = this.t("checkForUpdates"); + let result = + crate::updater::check_for_update(this.prefs.accept_beta); + match &result { + Ok(info) if info.available => { + this.latest_version = Some(info.latest_version.clone()); + this.latest_url = info.html_url.clone(); + this.update_status = crate::i18n::tf( + this.locale, + "updateAvailableTitle", + &[("version", &info.latest_version)], + ); + } + Ok(_) => { + this.latest_version = None; + this.update_status = + this.t("updateNotAvailableDescription"); + } + Err(err) => { + this.update_status = format!( + "{}: {err}", + this.t("updateCheckFailedTitle") + ); + } + } + if let Some(main) = this.main.upgrade() { + main.update(cx, |app, cx| { + app.apply_update_result(result, false); + cx.notify(); + }); + } + cx.notify(); + }); + } + }), + ) + .child( + Button::new("open-upd") + .label(if let Some(ver) = &self.latest_version { + crate::i18n::tf( + self.locale, + "installUpdate", + &[("version", ver)], + ) + } else { + self.t("openUpdatePage") + }) + .on_click({ + let url = self.latest_url.clone(); + move |_, _, _| { + let _ = open::that(&url); + } + }), + ), ) .child(switch_row( "beta", diff --git a/src-gpui/src/ui/shell.rs b/src-gpui/src/ui/shell.rs index 723a8b30..ca78e15d 100644 --- a/src-gpui/src/ui/shell.rs +++ b/src-gpui/src/ui/shell.rs @@ -1,17 +1,53 @@ -use gpui::{div, prelude::*, px, rgb, Context, Window}; -use gpui_component::{ActiveTheme, StyledExt}; +use gpui::{div, prelude::*, px, rgb, Context, MouseButton, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::{ActiveTheme, Sizable, StyledExt}; use crate::app::AppView; -use crate::state::Route; -use crate::ui::{debug, dialogs, palette, sidebar, workspace}; +use crate::state::{Route, ToastAction}; +use crate::ui::{debug, dialogs, palette, settings, sidebar, workspace}; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); + let view = cx.entity(); div() .flex() .size_full() .bg(theme.background) .text_color(theme.foreground) + .on_mouse_move({ + let view = view.clone(); + move |ev, _, cx| { + view.update(cx, |app, cx| { + let x = f32::from(ev.position.x); + let mut changed = false; + if let Some((start_x, start_w)) = app.data.overlay.sidebar_drag { + app.data.prefs.sidebar_width = (start_w + x - start_x).clamp(220.0, 420.0); + changed = true; + } + if let Some((start_x, start_w)) = app.data.overlay.profile_sidebar_drag { + app.data.prefs.profile_sidebar_width = + (start_w + x - start_x).clamp(180.0, 560.0); + changed = true; + } + if changed { + cx.notify(); + } + }); + } + }) + .on_mouse_up(MouseButton::Left, { + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.sidebar_drag.take().is_some() + || app.data.overlay.profile_sidebar_drag.take().is_some() + { + app.persist_prefs(); + cx.notify(); + } + }); + } + }) .child(sidebar::render(app, window, cx)) .child( div() @@ -46,6 +82,7 @@ fn home_or_empty( fn toasts(app: &AppView, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); + let view = cx.entity(); div() .id("toasts") .absolute() @@ -56,6 +93,7 @@ fn toasts(app: &AppView, cx: &mut Context) -> impl IntoElement { .gap_2() .children(app.data.toasts.iter().rev().take(4).map(|toast| { let bg = theme.background; + let action = toast.action; div() .id(crate::ui::eid(format!("toast-{}", toast.id))) .w(px(320.)) @@ -80,6 +118,21 @@ fn toasts(app: &AppView, cx: &mut Context) -> impl IntoElement { .child(toast.body.clone()), ) }) + .when(action == Some(ToastAction::OpenAbout), |el| { + el.child( + Button::new(crate::ui::eid(format!("toast-act-{}", toast.id))) + .xsmall() + .label(app.t("openUpdatePage")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + settings::open_update_page(app, window, cx); + }); + } + }), + ) + }) })) } @@ -91,12 +144,17 @@ fn window_controls() -> impl IntoElement { .right_0() .h(px(28.)) .flex() - .child(win_btn("min", "–", false)) - .child(win_btn("max", "□", false)) - .child(win_btn("close", "×", true)) + .child(win_btn("min", "–", false, |window| window.minimize_window())) + .child(win_btn("max", "□", false, |window| window.zoom_window())) + .child(win_btn("close", "×", true, |window| window.remove_window())) } -fn win_btn(id: &'static str, label: &'static str, close: bool) -> impl IntoElement { +fn win_btn( + id: &'static str, + label: &'static str, + close: bool, + on: impl Fn(&mut Window) + 'static, +) -> impl IntoElement { div() .id(id) .h(px(28.)) @@ -112,5 +170,6 @@ fn win_btn(id: &'static str, label: &'static str, close: bool) -> impl IntoEleme el.bg(gpui::hsla(0., 0., 0.5, 0.12)) } }) + .on_mouse_down(MouseButton::Left, move |_, window, _| on(window)) .child(label) } diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index ea3ad84c..f323e733 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -19,6 +19,11 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) let has_projects = !app.data.projects.is_empty(); let pad_top = if cfg!(target_os = "macos") { px(32.) } else { px(8.) }; + div() + .id("app-sidebar-wrap") + .relative() + .h_full() + .child( v_flex() .id("app-sidebar") .w(px(width)) @@ -138,9 +143,34 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }), ), ) + ) + .child(resize_handle("app-sidebar-resize", view, false)) .into_any_element() } +pub(crate) fn resize_handle(id: &'static str, view: gpui::Entity, profile: bool) -> impl IntoElement { + div() + .id(id) + .absolute() + .top_0() + .right_0() + .w(px(8.)) + .h_full() + .cursor(gpui::CursorStyle::ResizeColumn) + .on_mouse_down(MouseButton::Left, move |ev, _, cx| { + view.update(cx, |app, cx| { + let start = f32::from(ev.position.x); + if profile { + app.data.overlay.profile_sidebar_drag = + Some((start, app.data.prefs.profile_sidebar_width)); + } else { + app.data.overlay.sidebar_drag = Some((start, app.data.prefs.sidebar_width)); + } + cx.notify(); + }); + }) +} + fn project_sections( app: &mut AppView, _window: &mut Window, @@ -295,6 +325,7 @@ fn project_sections( .when(app.data.overlay.sort_mode, |el| { el.child( div() + .id("sidebar-drop-unpin") .mt_2() .px_2() .py_3() @@ -304,7 +335,18 @@ fn project_sections( .border_color(theme.border) .text_xs() .text_color(theme.muted_foreground) - .child(app.t("dropHereToUnpinOrMoveOut")), + .child(app.t("dropHereToUnpinOrMoveOut")) + .on_mouse_up(MouseButton::Left, { + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(id) = app.data.overlay.drag_project.take() { + app.drop_sidebar_project(&id, None, true); + cx.notify(); + } + }); + } + }), ) }) .when(app.data.overlay.onboarding && !has_any_projects(app), |el| { @@ -385,6 +427,9 @@ fn project_row( let default_profile = default_profile.clone(); move |_, _, cx| { view.update(cx, |app, cx| { + if app.data.overlay.sort_mode { + return; + } if let Some(pid) = default_profile.clone() { app.open_profile(&id, &pid); } @@ -392,6 +437,31 @@ fn project_row( }); } }) + .on_mouse_down(MouseButton::Left, { + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, _| { + if app.data.overlay.sort_mode { + app.data.overlay.drag_project = Some(id.clone()); + } + }); + } + }) + .on_mouse_up(MouseButton::Left, { + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(dragged) = app.data.overlay.drag_project.take() { + if dragged != id { + app.drop_sidebar_project(&dragged, Some(&id), false); + cx.notify(); + } + } + }); + } + }) .on_mouse_down(MouseButton::Right, { let view = view.clone(); let id = id.clone(); @@ -429,6 +499,38 @@ fn project_row( .when(app.data.overlay.sort_mode, |el| { let pinned = project.pinned_at.is_some(); el.child( + Button::new(crate::ui::eid(format!("up-{}", project.id))) + .ghost() + .xsmall() + .icon(IconName::ChevronUp) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.move_sidebar_project(&id, -1); + cx.notify(); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("down-{}", project.id))) + .ghost() + .xsmall() + .icon(IconName::ChevronDown) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.move_sidebar_project(&id, 1); + cx.notify(); + }); + } + }), + ) + .child( Button::new(crate::ui::eid(format!("pin-{}", project.id))) .ghost() .xsmall() diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index 1f4f85c9..82661283 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -4,6 +4,7 @@ use gpui_component::input::Input; use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; use crate::app::AppView; +use crate::prefs::TermTheme; pub fn render( app: &mut AppView, @@ -20,9 +21,16 @@ pub fn render( else { return div().id("term-missing").into_any_element(); }; - let text = term.screen_text(); let search_open = term.search_open; + let search_query = if search_open { + app.inputs.term_search.read(cx).value().to_string() + } else { + String::new() + }; + let hits = term.search_hits(&search_query); + let hit_ix = term.search_ix; let id = term.id.clone(); + let grid = render_grid(term, theme, &search_query, hit_ix); div() .id(crate::ui::eid(format!("pty-{id}"))) @@ -37,11 +45,24 @@ pub fn render( .id(crate::ui::eid(format!("pty-body-{id}"))) .size_full() .p_2() - .font_family("monospace") - .whitespace_nowrap() // may fail + .font_family(app.data.prefs.font_family.clone()) + .overflow_hidden() .on_key_down({ let view = view.clone(); move |ev: &KeyDownEvent, _, cx| { + if ev.keystroke.modifiers.control && ev.keystroke.key == "f" + || ev.keystroke.modifiers.platform && ev.keystroke.key == "f" + { + view.update(cx, |app, cx| { + if let Some(term) = + app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) + { + term.search_open = true; + } + cx.notify(); + }); + return; + } if let Some(bytes) = key_to_bytes(ev) { view.update(cx, |app, _| { app.write_to_active_pty(&bytes); @@ -49,11 +70,7 @@ pub fn render( } } }) - .child( - v_flex().children(text.lines().map(|line| { - div().child(line.to_string()) - })), - ), + .child(grid), ) .when(search_open, |el| { el.child( @@ -62,7 +79,7 @@ pub fn render( .absolute() .top(px(12.)) .right(px(12.)) - .w(px(280.)) + .w(px(320.)) .h(px(28.)) .px_2() .gap_1() @@ -72,19 +89,47 @@ pub fn render( .border_color(cx.theme().border) .shadow_md() .child(div().flex_1().child(Input::new(&app.inputs.term_search))) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(if hits.is_empty() { + "0/0".to_string() + } else { + format!("{}/{}", hit_ix + 1, hits.len()) + }), + ) .child( Button::new("term-search-prev") .ghost() .xsmall() .icon(IconName::ChevronUp) - .tooltip(app.t("terminalSearchPrevious")), + .tooltip(app.t("terminalSearchPrevious")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.cycle_term_search(cx, false); + cx.notify(); + }); + } + }), ) .child( Button::new("term-search-next") .ghost() .xsmall() .icon(IconName::ChevronDown) - .tooltip(app.t("terminalSearchNext")), + .tooltip(app.t("terminalSearchNext")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.cycle_term_search(cx, true); + cx.notify(); + }); + } + }), ) .child( Button::new("term-search-close") @@ -95,7 +140,11 @@ pub fn render( let view = view.clone(); move |_, _, cx| { view.update(cx, |app, cx| { - if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + if let Some(term) = app + .data + .current_ws_mut() + .and_then(|w| w.active_terminal_mut()) + { term.search_open = false; } cx.notify(); @@ -108,16 +157,127 @@ pub fn render( .into_any_element() } -fn key_to_bytes(ev: &KeyDownEvent) -> Option> { - let key = ev.keystroke.key.as_str(); - if ev.keystroke.modifiers.control && key == "c" { - return Some(vec![0x03]); +fn render_grid( + term: &crate::state::TermSession, + theme: &TermTheme, + query: &str, + hit_ix: usize, +) -> impl IntoElement { + let screen = term.parser.screen(); + let (rows, cols) = screen.size(); + let hits = term.search_hits(query); + let active = hits.get(hit_ix).copied(); + let query_len = query.len(); + + v_flex().id("pty-grid").children((0..rows).map(|row| { + let mut spans: Vec<(String, u32, u32, bool)> = Vec::new(); + for col in 0..cols { + let cell = screen.cell(row, col); + let ch = cell + .map(|c| { + let text = c.contents(); + if text.is_empty() { + " ".to_string() + } else { + text.to_string() + } + }) + .unwrap_or_else(|| " ".into()); + let (mut fg, mut bg) = cell + .map(|c| { + ( + map_color(c.fgcolor(), theme.fg), + map_color(c.bgcolor(), theme.bg), + ) + }) + .unwrap_or((theme.fg, theme.bg)); + if cell.map(|c| c.inverse()).unwrap_or(false) { + std::mem::swap(&mut fg, &mut bg); + } + if cell.map(|c| c.bold()).unwrap_or(false) { + fg = brighten(fg); + } + let highlight = hits.iter().any(|&(r, c, len)| { + r == row && (col as usize) >= c && (col as usize) < c + len + }); + if highlight { + bg = if active == Some((row, col as usize, query_len)) + || active.is_some_and(|(r, c, len)| { + r == row && (col as usize) >= c && (col as usize) < c + len + }) { + 0xe3b341 + } else { + 0x3b6ea8 + }; + fg = 0x0d1117; + } + if let Some((text, last_fg, last_bg, last_hi)) = spans.last_mut() { + if *last_fg == fg && *last_bg == bg && *last_hi == highlight { + text.push_str(&ch); + continue; + } + } + spans.push((ch, fg, bg, highlight)); + } + h_flex().children(spans.into_iter().map(|(text, fg, bg, _)| { + div() + .bg(rgb(bg)) + .text_color(rgb(fg)) + .whitespace_nowrap() + .child(text) + })) + })) +} + +fn map_color(color: vt100::Color, fallback: u32) -> u32 { + match color { + vt100::Color::Default => fallback, + vt100::Color::Idx(idx) => ansi_color(idx), + vt100::Color::Rgb(r, g, b) => ((r as u32) << 16) | ((g as u32) << 8) | b as u32, } - if ev.keystroke.modifiers.control && key == "d" { - return Some(vec![0x04]); +} + +fn ansi_color(idx: u8) -> u32 { + const BASIC: [u32; 16] = [ + 0x0d1117, 0xff7b72, 0x3fb950, 0xd29922, 0x58a6ff, 0xbc8cff, 0x39c5cf, 0xc9d1d9, 0x6e7681, + 0xffa198, 0x56d364, 0xe3b341, 0x79c0ff, 0xd2a8ff, 0x56d4dd, 0xffffff, + ]; + if (idx as usize) < 16 { + return BASIC[idx as usize]; } - if ev.keystroke.modifiers.control && key == "l" { - return Some(vec![0x0c]); + if idx >= 232 { + let v = 8 + (idx as u32 - 232) * 10; + return (v << 16) | (v << 8) | v; + } + let n = idx - 16; + let r = n / 36; + let g = (n / 6) % 6; + let b = n % 6; + let step = |c: u8| if c == 0 { 0 } else { 55 + 40 * c as u32 }; + (step(r) << 16) | (step(g) << 8) | step(b) +} + +fn brighten(color: u32) -> u32 { + let r = ((color >> 16) & 0xff).saturating_add(28).min(255); + let g = ((color >> 8) & 0xff).saturating_add(28).min(255); + let b = (color & 0xff).saturating_add(28).min(255); + (r << 16) | (g << 8) | b +} + +fn key_to_bytes(ev: &KeyDownEvent) -> Option> { + let key = ev.keystroke.key.as_str(); + if ev.keystroke.modifiers.control { + return match key { + "c" => Some(vec![0x03]), + "d" => Some(vec![0x04]), + "l" => Some(vec![0x0c]), + "u" => Some(vec![0x15]), + "k" => Some(vec![0x0b]), + "a" => Some(vec![0x01]), + "e" => Some(vec![0x05]), + "w" => Some(vec![0x17]), + _ => None, + }; } match key { "enter" => Some(b"\r".to_vec()), @@ -128,6 +288,11 @@ fn key_to_bytes(ev: &KeyDownEvent) -> Option> { "down" => Some(b"\x1b[B".to_vec()), "right" => Some(b"\x1b[C".to_vec()), "left" => Some(b"\x1b[D".to_vec()), + "home" => Some(b"\x1b[H".to_vec()), + "end" => Some(b"\x1b[F".to_vec()), + "pageup" => Some(b"\x1b[5~".to_vec()), + "pagedown" => Some(b"\x1b[6~".to_vec()), + "delete" => Some(b"\x1b[3~".to_vec()), other if other.len() == 1 => Some(other.as_bytes().to_vec()), _ => None, } diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 11183d61..dce264a6 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -5,7 +5,7 @@ use gpui_component::tab::Tab; use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; use crate::app::AppView; -use crate::state::{DialogKind, SidebarMode, UnifiedTab}; +use crate::state::{AgentKind, DialogKind, SidebarMode, UnifiedTab}; use crate::ui::{file_tree, file_viewer, git, notes, sidebar, terminal}; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -374,21 +374,29 @@ fn profile_sidebar( let mode = ws.sidebar_mode; let theme = cx.theme().clone(); + let view = cx.entity(); div() - .id("profile-sidebar") - .w(px(width)) + .id("profile-sidebar-wrap") + .relative() .h_full() - .border_r_1() - .border_color(theme.border) - .min_w_0() .child( - // Keep all three mounted; hide inactive ones. - v_flex() - .size_full() - .child(visible(mode == SidebarMode::Files, file_tree::render(app, window, cx))) - .child(visible(mode == SidebarMode::Git, git::render_panel(app, window, cx))) - .child(visible(mode == SidebarMode::Notes, notes::render(app, window, cx))), + div() + .id("profile-sidebar") + .w(px(width)) + .h_full() + .border_r_1() + .border_color(theme.border) + .min_w_0() + .child( + // Keep all three mounted; hide inactive ones. + v_flex() + .size_full() + .child(visible(mode == SidebarMode::Files, file_tree::render(app, window, cx))) + .child(visible(mode == SidebarMode::Git, git::render_panel(app, window, cx))) + .child(visible(mode == SidebarMode::Notes, notes::render(app, window, cx))), + ), ) + .child(sidebar::resize_handle("profile-sidebar-resize", view, true)) .into_any_element() } @@ -466,6 +474,7 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { .suffix( h_flex() .gap_1() + .child(agent_kind_mark(term.agent_kind)) .child(sidebar::agent_dot(term.agent)) .child( Button::new(crate::ui::eid(format!("close-term-{id}"))) @@ -596,6 +605,26 @@ fn new_terminal_control(app: &AppView, view: gpui::Entity) -> impl Into } } +fn agent_kind_mark(kind: AgentKind) -> impl IntoElement { + let (label, color) = match kind { + AgentKind::Claude => ("C", gpui::rgb(0xd97757)), + AgentKind::Codex => ("X", gpui::rgb(0x6b7280)), + AgentKind::Gemini => ("G", gpui::rgb(0x4285f4)), + AgentKind::Unknown => return div().into_any_element(), + }; + div() + .size(px(14.)) + .rounded_sm() + .bg(color) + .text_color(gpui::white()) + .flex() + .items_center() + .justify_center() + .text_xs() + .child(label) + .into_any_element() +} + fn tab_bodies( app: &mut AppView, window: &mut Window, diff --git a/src-gpui/src/updater.rs b/src-gpui/src/updater.rs new file mode 100644 index 00000000..25babfd8 --- /dev/null +++ b/src-gpui/src/updater.rs @@ -0,0 +1,153 @@ +use serde::Deserialize; + +const GITHUB_RELEASES_API: &str = + "https://api.github.com/repos/AkaraChen/2code/releases?per_page=20"; +const GITHUB_RELEASES_PAGE: &str = "https://github.com/AkaraChen/2code/releases"; + +#[derive(Debug, Clone)] +pub struct UpdateInfo { + pub current_version: String, + pub latest_version: String, + pub available: bool, + pub prerelease: bool, + pub html_url: String, +} + +#[derive(Debug, Deserialize)] +struct GithubRelease { + tag_name: String, + #[serde(default)] + draft: bool, + #[serde(default)] + prerelease: bool, + #[serde(default)] + html_url: String, +} + +pub fn current_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +pub fn releases_page() -> &'static str { + GITHUB_RELEASES_PAGE +} + +pub fn check_for_update(accept_beta: bool) -> Result { + let mut request = ureq::get(GITHUB_RELEASES_API) + .set("accept", "application/vnd.github+json") + .set("user-agent", "2code-gpui"); + + if let Some(token) = gh_auth_token() { + request = request.set("authorization", &format!("Bearer {token}")); + } + + let releases: Vec = request + .call() + .map_err(|e| e.to_string())? + .into_json() + .map_err(|e| e.to_string())?; + + let release = pick_release(&releases, accept_beta) + .ok_or_else(|| "no matching GitHub release".to_string())?; + let latest = release.tag_name.trim_start_matches('v').to_string(); + let current = current_version().to_string(); + Ok(UpdateInfo { + available: is_newer(&latest, ¤t), + latest_version: latest, + current_version: current, + prerelease: release.prerelease, + html_url: if release.html_url.is_empty() { + GITHUB_RELEASES_PAGE.to_string() + } else { + release.html_url.clone() + }, + }) +} + +fn pick_release(releases: &[GithubRelease], accept_beta: bool) -> Option<&GithubRelease> { + if accept_beta { + if let Some(beta) = releases.iter().find(|release| { + !release.draft + && release.prerelease + && release.tag_name.to_ascii_lowercase().contains("beta") + }) { + return Some(beta); + } + } + releases + .iter() + .find(|release| !release.draft && !release.prerelease) + .or_else(|| releases.iter().find(|release| !release.draft)) +} + +fn gh_auth_token() -> Option { + let output = std::process::Command::new("gh") + .args(["auth", "token"]) + .env("GH_PROMPT_DISABLED", "1") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let token = String::from_utf8(output.stdout).ok()?.trim().to_string(); + if token.is_empty() { + None + } else { + Some(token) + } +} + +fn version_parts(raw: &str) -> Vec { + raw.trim() + .trim_start_matches('v') + .split(|c: char| !c.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse().ok()) + .take(3) + .collect() +} + +fn is_newer(latest: &str, current: &str) -> bool { + let mut latest_parts = version_parts(latest); + let mut current_parts = version_parts(current); + while latest_parts.len() < 3 { + latest_parts.push(0); + } + while current_parts.len() < 3 { + current_parts.push(0); + } + latest_parts > current_parts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn newer_tag_wins() { + assert!(is_newer("3.1.0", "3.0.1")); + assert!(!is_newer("3.0.1", "3.0.1")); + assert!(!is_newer("v3.0.0", "3.0.1")); + assert!(is_newer("v4.0.0-beta.1", "3.0.1")); + } + + #[test] + fn prefers_beta_when_requested() { + let releases = vec![ + GithubRelease { + tag_name: "v3.1.0".into(), + draft: false, + prerelease: false, + html_url: String::new(), + }, + GithubRelease { + tag_name: "v3.2.0-beta.1".into(), + draft: false, + prerelease: true, + html_url: String::new(), + }, + ]; + assert_eq!(pick_release(&releases, true).unwrap().tag_name, "v3.2.0-beta.1"); + assert_eq!(pick_release(&releases, false).unwrap().tag_name, "v3.1.0"); + } +} From e7076a6d5120f8e2b5a07a0356564760f6d7e9d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:40:41 +0000 Subject: [PATCH 04/50] Add GPUI file-tree rename, fonts/sounds, detector, and watcher. Inline rename and drag-to-PTY close more file-tree inventory gaps. Settings now lists system fonts and notification sounds. Agent status uses a rule-based detector and can play the waiting sound. A filesystem watcher refreshes the tree, and just start launches the GPUI app instead of the Tauri shell. Co-authored-by: AkaraChen --- README.md | 2 + justfile | 3 + src-gpui/Cargo.lock | 1 + src-gpui/Cargo.toml | 1 + src-gpui/src/app.rs | 122 ++++++++++++- src-gpui/src/backend.rs | 40 +++++ src-gpui/src/detector.rs | 156 ++++++++++++++++ src-gpui/src/main.rs | 2 + src-gpui/src/platform.rs | 337 +++++++++++++++++++++++++++++++++++ src-gpui/src/state.rs | 33 ++-- src-gpui/src/ui/dialogs.rs | 7 +- src-gpui/src/ui/file_tree.rs | 63 ++++++- src-gpui/src/ui/settings.rs | 52 ++++++ src-gpui/src/ui/terminal.rs | 41 +++++ src-gpui/src/ui/workspace.rs | 8 + 15 files changed, 846 insertions(+), 22 deletions(-) create mode 100644 src-gpui/src/detector.rs create mode 100644 src-gpui/src/platform.rs diff --git a/README.md b/README.md index b4b0da9a..45a3e119 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ AI-assisted development creates more parallel state than a normal coding session ### Run the desktop app ```bash +just start +# or just gpui # or cd src-gpui && cargo run diff --git a/justfile b/justfile index d71b1486..d9935734 100644 --- a/justfile +++ b/justfile @@ -6,6 +6,9 @@ gpui-check: cd src-gpui && cargo check start: + just gpui + +tauri-dev: ./start.sh fmt: diff --git a/src-gpui/Cargo.lock b/src-gpui/Cargo.lock index 151ce68b..36eebf53 100644 --- a/src-gpui/Cargo.lock +++ b/src-gpui/Cargo.lock @@ -950,6 +950,7 @@ dependencies = [ "anyhow", "diesel", "dirs 6.0.0", + "fontdb 0.23.0", "gpui", "gpui-component", "infra", diff --git a/src-gpui/Cargo.toml b/src-gpui/Cargo.toml index 9f65b1ec..69790077 100644 --- a/src-gpui/Cargo.toml +++ b/src-gpui/Cargo.toml @@ -29,6 +29,7 @@ vt100 = "0.16" uuid = { version = "1", features = ["v4"] } open = "5" ureq = { version = "2", features = ["json"] } +fontdb = "0.23" [target.'cfg(target_os = "windows")'.dependencies] libsqlite3-sys = { version = "0.37", features = ["bundled"] } diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 5c270365..205873f3 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -652,21 +652,59 @@ impl AppView { if bytes.is_empty() { continue; } + let mut became_waiting = false; if let Some(term) = self .data .workspaces .get_mut(&profile_id) .and_then(|w| w.terminals.iter_mut().find(|t| t.id == session_id)) { + let before = term.agent; term.feed(&bytes); + became_waiting = before != AgentStatus::Waiting && term.agent == AgentStatus::Waiting; + } + if became_waiting { + self.notify_agent_waiting(); } } if let Ok(exits) = self.backend.exits.lock() { let _ = exits.len(); } + self.drain_watch_events(); self.autosave_notes(cx); } + fn notify_agent_waiting(&self) { + if !self.data.prefs.notifications { + return; + } + let sound = self.data.prefs.notification_sound.clone(); + if !sound.is_empty() { + let _ = crate::platform::play_system_sound(&sound); + } + } + + fn drain_watch_events(&mut self) { + let events = self.backend.take_watch_events(); + if events.is_empty() { + return; + } + let mut profiles = std::collections::HashSet::new(); + for event in events { + if let Some(pid) = event.profile_id { + profiles.insert(pid); + } else if let Some(default) = self.data.default_profile_of(&event.project_id) { + profiles.insert(default.id); + } + } + for profile_id in profiles { + if self.data.workspaces.contains_key(&profile_id) { + self.load_tree_root(&profile_id); + self.refresh_workspace(&profile_id); + } + } + } + fn autosave_notes(&mut self, cx: &mut Context) { let Some(profile_id) = self.data.current_profile.clone() else { return; @@ -1188,7 +1226,88 @@ impl AppView { } } - pub fn create_path(&mut self, is_dir: bool, cx: &mut Context) { + pub fn start_rename_path(&mut self, path: &str, window: &mut Window, cx: &mut Context) { + self.data.overlay.renaming_path = Some(path.to_string()); + let name = backend::file_name(path); + self.inputs.rename.update(cx, |s, cx| { + s.set_value(name, window, cx); + }); + } + + pub fn commit_rename_path(&mut self, cx: &mut Context) { + let Some(from) = self.data.overlay.renaming_path.clone() else { + return; + }; + let name = self.inputs.rename.read(cx).value().to_string(); + if name.trim().is_empty() { + self.data.overlay.renaming_path = None; + return; + } + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let parent = std::path::Path::new(&from) + .parent() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + let to = if parent.is_empty() { + name + } else { + format!("{parent}/{name}") + }; + if let Err(err) = self.backend.rename_path(&profile_id, &from, &to) { + self.data.push_toast( + ToastKind::Error, + self.t("fileTreeCreateErrorTitle"), + err.to_string(), + ); + return; + } + self.data.overlay.renaming_path = None; + self.load_tree_root(&profile_id); + } + + pub fn drop_file_on_terminal(&mut self) { + let Some(path) = self.data.overlay.drag_file.take() else { + return; + }; + let quoted = if path.contains(' ') { + format!("\"{path}\" ") + } else { + format!("{path} ") + }; + self.write_to_active_pty(quoted.as_bytes()); + } + + pub fn open_clickable(&mut self, token: &crate::detector::Clickable, window: &mut Window, cx: &mut Context) { + match token { + crate::detector::Clickable::Url(url) => { + self.data.overlay.dialog = Some(DialogKind::OpenLink); + self.data.overlay.dialog_url = Some(url.clone()); + } + crate::detector::Clickable::Path(path) => { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + match self.backend.resolve_file(&profile_id, path) { + Ok(model::filesystem::ResolvedFilePath::Exact { path }) => { + self.open_file(&profile_id, &path, window, cx); + } + Ok(model::filesystem::ResolvedFilePath::Fuzzy { candidates }) => { + self.data.overlay.dialog = Some(DialogKind::ChooseFile); + self.data.overlay.fuzzy_files = candidates; + } + Err(err) => self.data.push_toast( + ToastKind::Error, + self.t("somethingWentWrong"), + err.to_string(), + ), + } + } + } + } + + pub fn create_path(&mut self, is_dir: bool, window: &mut Window, cx: &mut Context) { let Some(profile_id) = self.data.current_profile.clone() else { return; }; @@ -1211,6 +1330,7 @@ impl AppView { return; } self.load_tree_root(&profile_id); + self.start_rename_path(&name, window, cx); } pub fn delete_tree_path(&mut self, path: &str) { diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index f0c9d889..ff57242e 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -18,6 +18,7 @@ use service::{PtyEventEmitter, WatchEventSender}; pub type TerminalBuffers = Arc>>>; pub type TerminalExits = Arc>>; +pub type WatchInbox = Arc>>; pub struct GpuiPtyEmitter { buffers: TerminalBuffers, @@ -41,6 +42,19 @@ impl PtyEventEmitter for GpuiPtyEmitter { } } +struct GpuiWatchSender { + events: WatchInbox, +} + +impl WatchEventSender for GpuiWatchSender { + fn send(&self, event: model::watcher::WatchEvent) -> bool { + if let Ok(mut queue) = self.events.lock() { + queue.push(event); + } + true + } +} + struct NoopWatchSender; impl WatchEventSender for NoopWatchSender { @@ -59,6 +73,7 @@ pub struct Backend { pub output_dir: PathBuf, pub buffers: TerminalBuffers, pub exits: TerminalExits, + pub watch_events: WatchInbox, } impl Backend { @@ -74,6 +89,14 @@ impl Backend { let buffers = Arc::new(Mutex::new(HashMap::new())); let exits = Arc::new(Mutex::new(Vec::new())); + let watch_events = Arc::new(Mutex::new(Vec::new())); + service::watcher::start( + Box::new(GpuiWatchSender { + events: watch_events.clone(), + }), + db.clone(), + infra::watcher::create_shutdown_flag(), + ); Ok(Self { db, @@ -84,6 +107,7 @@ impl Backend { output_dir, buffers, exits, + watch_events, }) } @@ -477,6 +501,22 @@ impl Backend { self.with_db(|conn| service::pty::list_project_sessions(conn, project_id)) } + pub fn take_watch_events(&self) -> Vec { + self.watch_events + .lock() + .ok() + .map(|mut q| q.drain(..).collect()) + .unwrap_or_default() + } + + pub fn resolve_file( + &self, + profile_id: &str, + path: &str, + ) -> Result { + service::filesystem::resolve_terminal_file_path(&self.db, profile_id, path) + } + pub fn take_output(&self, session_id: &str) -> Vec { self.buffers .lock() diff --git a/src-gpui/src/detector.rs b/src-gpui/src/detector.rs new file mode 100644 index 00000000..300f32ce --- /dev/null +++ b/src-gpui/src/detector.rs @@ -0,0 +1,156 @@ +use crate::state::{AgentKind, AgentStatus}; + +pub fn detect(title: &str, screen: &str, previous: AgentStatus) -> (AgentKind, AgentStatus) { + let hay = format!("{title}\n{screen}").to_ascii_lowercase(); + let kind = detect_kind(&hay); + let status = detect_status(&hay, previous); + (kind, status) +} + +fn detect_kind(hay: &str) -> AgentKind { + const RULES: &[(&str, AgentKind)] = &[ + ("claude-code", AgentKind::Claude), + ("claude", AgentKind::Claude), + ("codex", AgentKind::Codex), + ("gemini", AgentKind::Gemini), + ("cursor", AgentKind::Cursor), + ("github copilot", AgentKind::Copilot), + ("copilot", AgentKind::Copilot), + ("amp", AgentKind::Amp), + ("cline", AgentKind::Cline), + ("opencode", AgentKind::OpenCode), + ("grok", AgentKind::Grok), + ("kimi", AgentKind::Kimi), + ("aider", AgentKind::Other), + ]; + for (needle, kind) in RULES { + if hay.contains(needle) { + return *kind; + } + } + AgentKind::Unknown +} + +fn detect_status(hay: &str, previous: AgentStatus) -> AgentStatus { + if is_blocked(hay) { + return AgentStatus::Waiting; + } + if is_working(hay) { + return AgentStatus::Running; + } + if is_idle_prompt(hay) { + return if matches!(previous, AgentStatus::Running | AgentStatus::Waiting) { + AgentStatus::Completed + } else { + AgentStatus::Idle + }; + } + previous +} + +fn is_blocked(hay: &str) -> bool { + const PHRASES: &[&str] = &[ + "waiting for permission", + "waiting for", + "waiting on", + "allow command?", + "action required", + "press enter to confirm or esc to cancel", + "enter to submit answer", + "enter to select", + "esc to cancel", + "do you want to continue", + "do you want to proceed", + "[y/n]", + "yes (y)", + "would you like to", + ]; + PHRASES.iter().any(|p| hay.contains(p)) +} + +fn is_working(hay: &str) -> bool { + const PHRASES: &[&str] = &[ + "thinking", + "running", + "working", + "compiling", + "generating", + "esc to cancel", + ]; + if hay.contains("esc to cancel") && hay.contains("enter to select") { + return false; + } + PHRASES.iter().any(|p| hay.contains(p)) || has_braille_spinner(hay) +} + +fn is_idle_prompt(hay: &str) -> bool { + hay.contains("❯") + || hay.contains("idle") + || hay.lines().rev().take(6).any(|line| { + let t = line.trim(); + t == ">" || t.ends_with(" $") || t.ends_with('$') || t.starts_with("❯") + }) +} + +fn has_braille_spinner(hay: &str) -> bool { + hay.chars() + .any(|c| ('\u{2800}'..='\u{28FF}').contains(&c)) +} + +pub fn clickable_tokens(screen: &str) -> Vec { + let mut out = Vec::new(); + for token in screen.split_whitespace() { + let clean = token.trim_matches(|c: char| { + matches!(c, ',' | ';' | ')' | '(' | '[' | ']' | '"' | '\'' | '`') + }); + if clean.starts_with("http://") || clean.starts_with("https://") { + out.push(Clickable::Url(clean.to_string())); + } else if looks_like_path(clean) { + out.push(Clickable::Path(clean.to_string())); + } + } + out.dedup(); + out.into_iter().take(8).collect() +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Clickable { + Url(String), + Path(String), +} + +fn looks_like_path(token: &str) -> bool { + if token.len() < 3 || token.contains("://") { + return false; + } + (token.contains('/') || token.contains('.')) + && token + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | '~')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_claude_waiting() { + let (kind, status) = detect("claude", "waiting for permission", AgentStatus::Running); + assert_eq!(kind, AgentKind::Claude); + assert_eq!(status, AgentStatus::Waiting); + } + + #[test] + fn detects_codex_working() { + let (kind, status) = detect("codex", "thinking about the change", AgentStatus::Idle); + assert_eq!(kind, AgentKind::Codex); + assert_eq!(status, AgentStatus::Running); + } + + #[test] + fn extracts_urls_and_paths() { + let tokens = clickable_tokens("see https://example.com and src/app.rs please"); + assert!(tokens.contains(&Clickable::Url("https://example.com".into()))); + assert!(tokens.contains(&Clickable::Path("src/app.rs".into()))); + } +} diff --git a/src-gpui/src/main.rs b/src-gpui/src/main.rs index 121d18aa..b2385cee 100644 --- a/src-gpui/src/main.rs +++ b/src-gpui/src/main.rs @@ -1,6 +1,8 @@ mod app; mod backend; +mod detector; mod i18n; +mod platform; mod prefs; mod state; mod ui; diff --git a/src-gpui/src/platform.rs b/src-gpui/src/platform.rs new file mode 100644 index 00000000..412f6a4c --- /dev/null +++ b/src-gpui/src/platform.rs @@ -0,0 +1,337 @@ +use std::sync::OnceLock; + +#[derive(Clone, Debug)] +pub struct SystemFont { + pub family: String, + pub is_mono: bool, +} + +pub fn list_system_fonts() -> Vec { + static FONTS: OnceLock> = OnceLock::new(); + FONTS.get_or_init(load_fonts).clone() +} + +pub fn list_mono_fonts() -> Vec { + let mut fonts: Vec = list_system_fonts() + .into_iter() + .filter(|f| f.is_mono) + .map(|f| f.family) + .collect(); + if fonts.is_empty() { + fonts = list_system_fonts() + .into_iter() + .map(|f| f.family) + .collect(); + } + fonts +} + +fn load_fonts() -> Vec { + let mut db = fontdb::Database::new(); + db.load_system_fonts(); + let mut families = std::collections::BTreeMap::new(); + for face in db.faces() { + for (family, _) in &face.families { + let family = family.trim(); + if family.is_empty() { + continue; + } + families + .entry(family.to_string()) + .and_modify(|mono: &mut bool| *mono = *mono || face.monospaced) + .or_insert(face.monospaced); + } + } + families + .into_iter() + .map(|(family, is_mono)| SystemFont { family, is_mono }) + .collect() +} + +pub fn list_system_sounds() -> Vec { + static SOUNDS: OnceLock> = OnceLock::new(); + SOUNDS.get_or_init(load_sounds).clone() +} + +pub fn play_system_sound(name: &str) -> Result<(), String> { + if name.is_empty() { + return Ok(()); + } + play_sound_name(name) +} + +fn is_valid_sound_name(name: &str) -> bool { + !name.is_empty() && !name.contains("..") && !name.contains('/') && !name.contains('\\') +} + +fn load_sounds() -> Vec { + #[cfg(target_os = "macos")] + { + return list_macos_sounds(); + } + #[cfg(target_os = "linux")] + { + return list_linux_sounds(); + } + #[cfg(target_os = "windows")] + { + return list_windows_sounds(); + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + Vec::new() + } +} + +fn play_sound_name(name: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + return play_macos_sound(name); + } + #[cfg(target_os = "linux")] + { + return play_linux_sound(name); + } + #[cfg(target_os = "windows")] + { + return play_windows_sound(name); + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = name; + Ok(()) + } +} + +#[cfg(target_os = "macos")] +fn list_macos_sounds() -> Vec { + let mut sounds: Vec = std::fs::read_dir("/System/Library/Sounds") + .into_iter() + .flatten() + .filter_map(|entry| { + let name = entry.ok()?.file_name().into_string().ok()?; + name.strip_suffix(".aiff").map(|s| s.to_string()) + }) + .collect(); + sounds.sort(); + sounds +} + +#[cfg(target_os = "macos")] +fn play_macos_sound(name: &str) -> Result<(), String> { + if !is_valid_sound_name(name) { + return Err(format!("Sound not found: {name}")); + } + let path = std::path::Path::new("/System/Library/Sounds").join(format!("{name}.aiff")); + if !path.exists() { + return Err(format!("Sound not found: {name}")); + } + std::process::Command::new("afplay") + .arg(&path) + .spawn() + .map_err(|e| format!("Failed to play sound: {e}"))?; + Ok(()) +} + +#[cfg(target_os = "linux")] +const LINUX_SOUND_EXTENSIONS: &[&str] = &["oga", "ogg", "wav", "aiff", "aif"]; + +#[cfg(target_os = "linux")] +fn list_linux_sounds() -> Vec { + use std::collections::BTreeSet; + let mut sounds = BTreeSet::new(); + for root in linux_sound_roots() { + collect_linux_sounds(&root, &mut sounds); + } + sounds.into_iter().collect() +} + +#[cfg(target_os = "linux")] +fn play_linux_sound(name: &str) -> Result<(), String> { + let path = find_linux_sound_file(name).ok_or_else(|| format!("Sound not found: {name}"))?; + if command_in_path("canberra-gtk-play") { + return spawn_player("canberra-gtk-play", &["-i", name]); + } + let path = path + .to_str() + .ok_or_else(|| format!("Sound path is not valid UTF-8: {}", path.display()))?; + if command_in_path("pw-play") { + return spawn_player("pw-play", &[path]); + } + if command_in_path("paplay") { + return spawn_player("paplay", &[path]); + } + Err("No Linux sound player found.".into()) +} + +#[cfg(target_os = "linux")] +fn linux_sound_roots() -> Vec { + let mut dirs = Vec::new(); + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + dirs.push(std::path::PathBuf::from(data_home)); + } else if let Some(home) = std::env::var_os("HOME") { + dirs.push(std::path::PathBuf::from(home).join(".local/share")); + } + if let Some(data_dirs) = std::env::var_os("XDG_DATA_DIRS") { + dirs.extend(std::env::split_paths(&data_dirs)); + } else { + dirs.push(std::path::PathBuf::from("/usr/local/share")); + dirs.push(std::path::PathBuf::from("/usr/share")); + } + dirs.into_iter() + .map(|dir| dir.join("sounds")) + .filter(|dir| dir.is_dir()) + .collect() +} + +#[cfg(target_os = "linux")] +fn collect_linux_sounds(dir: &std::path::Path, sounds: &mut std::collections::BTreeSet) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_linux_sounds(&path, sounds); + continue; + } + if !is_linux_sound_file(&path) { + continue; + } + if let Some(name) = path.file_stem().and_then(|name| name.to_str()) { + sounds.insert(name.to_string()); + } + } +} + +#[cfg(target_os = "linux")] +fn find_linux_sound_file(name: &str) -> Option { + if !is_valid_sound_name(name) { + return None; + } + for root in linux_sound_roots() { + if let Some(path) = find_linux_sound_file_in_dir(&root, name) { + return Some(path); + } + } + None +} + +#[cfg(target_os = "linux")] +fn find_linux_sound_file_in_dir(dir: &std::path::Path, name: &str) -> Option { + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_linux_sound_file_in_dir(&path, name) { + return Some(found); + } + continue; + } + if is_linux_sound_file(&path) + && path.file_stem().and_then(|stem| stem.to_str()) == Some(name) + { + return Some(path); + } + } + None +} + +#[cfg(target_os = "linux")] +fn is_linux_sound_file(path: &std::path::Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| { + LINUX_SOUND_EXTENSIONS + .iter() + .any(|allowed| extension.eq_ignore_ascii_case(allowed)) + }) + .unwrap_or(false) +} + +#[cfg(target_os = "linux")] +fn command_in_path(command: &str) -> bool { + std::env::var_os("PATH") + .map(|paths| { + std::env::split_paths(&paths).any(|path| path.join(command).is_file()) + }) + .unwrap_or(false) +} + +#[cfg(target_os = "linux")] +fn spawn_player(program: &str, args: &[&str]) -> Result<(), String> { + std::process::Command::new(program) + .args(args) + .spawn() + .map_err(|e| format!("Failed to play sound: {e}"))?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn windows_sounds_dir() -> std::path::PathBuf { + std::path::PathBuf::from(r"C:\Windows\Media") +} + +#[cfg(target_os = "windows")] +fn list_windows_sounds() -> Vec { + use std::collections::BTreeSet; + let mut sounds = BTreeSet::new(); + collect_windows_sounds(&windows_sounds_dir(), &mut sounds); + sounds.into_iter().collect() +} + +#[cfg(target_os = "windows")] +fn collect_windows_sounds(dir: &std::path::Path, sounds: &mut std::collections::BTreeSet) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_windows_sounds(&path, sounds); + continue; + } + if path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("wav")) { + if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { + sounds.insert(name.to_string()); + } + } + } +} + +#[cfg(target_os = "windows")] +fn play_windows_sound(name: &str) -> Result<(), String> { + if !is_valid_sound_name(name) { + return Err(format!("Sound not found: {name}")); + } + let path = find_windows_sound_file(name).ok_or_else(|| format!("Sound not found: {name}"))?; + std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + &format!( + "(New-Object Media.SoundPlayer '{}').PlaySync()", + path.display() + ), + ]) + .spawn() + .map_err(|e| format!("Failed to play sound: {e}"))?; + Ok(()) +} + +#[cfg(target_os = "windows")] +fn find_windows_sound_file(name: &str) -> Option { + fn walk(dir: &std::path::Path, name: &str) -> Option { + for entry in std::fs::read_dir(dir).ok()?.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = walk(&path, name) { + return Some(found); + } + } else if path.file_stem().and_then(|s| s.to_str()) == Some(name) { + return Some(path); + } + } + None + } + walk(&windows_sounds_dir(), name) +} diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 02982fee..d6e9805c 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -65,6 +65,14 @@ pub enum AgentKind { Claude, Codex, Gemini, + Cursor, + Copilot, + Amp, + Cline, + OpenCode, + Grok, + Kimi, + Other, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -192,24 +200,15 @@ impl TermSession { fn detect_agent(&mut self) { let text = self.parser.screen().contents(); - let lower = format!("{} {}", self.title, text).to_ascii_lowercase(); - self.agent_kind = if lower.contains("claude") { - AgentKind::Claude - } else if lower.contains("codex") { - AgentKind::Codex - } else if lower.contains("gemini") { - AgentKind::Gemini - } else { - self.agent_kind - }; - if lower.contains("waiting for") || lower.contains("waiting on") { - self.agent = AgentStatus::Waiting; - } else if lower.contains("running") || lower.contains("thinking") { - self.agent = AgentStatus::Running; - } else if matches!(self.agent, AgentStatus::Running | AgentStatus::Waiting) { - self.agent = AgentStatus::Completed; + let prev = self.agent; + let (kind, status) = crate::detector::detect(&self.title, &text, prev); + if kind != AgentKind::Unknown { + self.agent_kind = kind; + } + if status != prev && matches!(status, AgentStatus::Completed) { self.completed_hidden = false; } + self.agent = status; } pub fn screen_text(&self) -> String { @@ -337,6 +336,8 @@ pub struct OverlayState { pub sidebar_drag: Option<(f32, f32)>, pub profile_sidebar_drag: Option<(f32, f32)>, pub drag_project: Option, + pub drag_file: Option, + pub renaming_path: Option, pub update_checked: bool, } diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index d2acc0af..819111ee 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -684,8 +684,11 @@ fn run_menu( app.load_tree_root(&pid); } } - (_, MenuAction::NewFile) => app.create_path(false, cx), - (_, MenuAction::NewFolder) => app.create_path(true, cx), + (ContextMenu::File { path }, MenuAction::RenamePath) => { + app.start_rename_path(path, window, cx); + } + (_, MenuAction::NewFile) => app.create_path(false, window, cx), + (_, MenuAction::NewFolder) => app.create_path(true, window, cx), (ContextMenu::File { path }, MenuAction::CopyRel) => app.copy_path(path, false, cx), (ContextMenu::File { path }, MenuAction::CopyAbs) => app.copy_path(path, true, cx), (ContextMenu::File { path }, MenuAction::DeletePath) => app.delete_tree_path(path), diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index 0cc18d19..e5e853ff 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -1,5 +1,7 @@ use gpui::{div, prelude::*, px, Context, MouseButton, Window}; -use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, StyledExt}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::Input; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; use crate::app::AppView; use crate::state::ContextMenu; @@ -83,6 +85,7 @@ fn node_view( let indent = px(12. * depth as f32 + 4.); let path_owned = path.to_string(); let profile_owned = profile.to_string(); + let renaming = app.data.overlay.renaming_path.as_deref() == Some(path); v_flex() .child( @@ -102,6 +105,9 @@ fn node_view( let is_dir = node.is_dir; move |_, window, cx| { view.update(cx, |app, cx| { + if app.data.overlay.renaming_path.as_deref() == Some(path.as_str()) { + return; + } if is_dir { app.toggle_dir(&profile, &path); } else { @@ -111,6 +117,19 @@ fn node_view( }); } }) + .on_mouse_down(MouseButton::Left, { + let view = view.clone(); + let path = path_owned.clone(); + let is_dir = node.is_dir; + move |_, _, cx| { + if is_dir { + return; + } + view.update(cx, |app, _| { + app.data.overlay.drag_file = Some(path.clone()); + }); + } + }) .on_mouse_down(MouseButton::Right, { let view = view.clone(); let path = path_owned.clone(); @@ -134,8 +153,46 @@ fn node_view( } else { IconName::File }).w(px(13.))) - .child(div().flex_1().child(node.name.clone())) - .when_some(status, |el, st| { + .child(if renaming { + h_flex() + .flex_1() + .gap_1() + .child(div().flex_1().child(Input::new(&app.inputs.rename))) + .child( + Button::new(crate::ui::eid(format!("rename-ok-{path}"))) + .xsmall() + .primary() + .icon(IconName::Check) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.commit_rename_path(cx); + cx.notify(); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("rename-cancel-{path}"))) + .xsmall() + .ghost() + .icon(IconName::Close) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.renaming_path = None; + cx.notify(); + }); + } + }), + ) + .into_any_element() + } else { + div().flex_1().child(node.name.clone()).into_any_element() + }) + .when_some(status.filter(|_| !renaming), |el, st| { el.child( div() .text_xs() diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index ca61a1b2..4183d3f3 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -99,6 +99,8 @@ pub struct SettingsView { update_status: String, latest_version: Option, latest_url: String, + fonts: Vec, + sounds: Vec, } impl SettingsView { @@ -150,6 +152,8 @@ impl SettingsView { update_status: crate::i18n::t(locale, "updateIdleDescription"), latest_version: None, latest_url: crate::updater::releases_page().to_string(), + fonts: crate::platform::list_mono_fonts(), + sounds: crate::platform::list_system_sounds(), } } @@ -409,6 +413,27 @@ impl SettingsView { .child(Input::new(&self.custom_shell)) .child(field_label(&self.t("terminalFont"))) .child(div().text_sm().child(self.prefs.font_family.clone())) + .child( + div() + .max_h(px(140.)) + .overflow_y_hidden() + .child( + h_flex().gap_1().flex_wrap().children(self.fonts.iter().cloned().take(48).map(|family| { + let selected = self.prefs.font_family == family; + choice(format!("font-{family}"), &family, selected, { + let view = view.clone(); + let family = family.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.font_family = family.clone(); + this.persist(cx); + cx.notify(); + }); + } + }) + })), + ), + ) .child(field_label(&self.t("fontSize"))) .child( h_flex() @@ -576,6 +601,33 @@ impl SettingsView { } else { self.prefs.notification_sound.clone() })) + .child( + h_flex().gap_1().flex_wrap().children( + std::iter::once(String::new()) + .chain(self.sounds.iter().cloned()) + .take(36) + .map(|name| { + let selected = self.prefs.notification_sound == name; + let label = if name.is_empty() { + self.t("notificationSoundNone") + } else { + name.clone() + }; + choice(format!("snd-{name}"), &label, selected, { + let view = view.clone(); + let name = name.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.notification_sound = name.clone(); + let _ = crate::platform::play_system_sound(&name); + this.persist(cx); + cx.notify(); + }); + } + }) + }), + ), + ) } fn topbar(&mut self, cx: &mut Context) -> impl IntoElement { diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index 82661283..de04c843 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -30,6 +30,7 @@ pub fn render( let hits = term.search_hits(&search_query); let hit_ix = term.search_ix; let id = term.id.clone(); + let clickables = crate::detector::clickable_tokens(&term.screen_text()); let grid = render_grid(term, theme, &search_query, hit_ix); div() @@ -40,6 +41,17 @@ pub fn render( .text_color(rgb(theme.fg)) .font_family(app.data.prefs.font_family.clone()) .text_size(px(app.data.prefs.font_size)) + .on_mouse_up(gpui::MouseButton::Left, { + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.drag_file.is_some() { + app.drop_file_on_terminal(); + cx.notify(); + } + }); + } + }) .child( div() .id(crate::ui::eid(format!("pty-body-{id}"))) @@ -154,6 +166,35 @@ pub fn render( ), ) }) + .when(!clickables.is_empty(), |el| { + el.child( + h_flex() + .id("pty-links") + .absolute() + .bottom(px(8.)) + .left(px(8.)) + .right(px(8.)) + .gap_1() + .flex_wrap() + .children(clickables.into_iter().map(|token| { + let label = match &token { + crate::detector::Clickable::Url(u) => u.clone(), + crate::detector::Clickable::Path(p) => p.clone(), + }; + let view = view.clone(); + Button::new(crate::ui::eid(format!("pty-link-{label}"))) + .xsmall() + .label(label) + .on_click(move |_, window, cx| { + let token = token.clone(); + view.update(cx, |app, cx| { + app.open_clickable(&token, window, cx); + cx.notify(); + }); + }) + })), + ) + }) .into_any_element() } diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index dce264a6..668f9030 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -610,6 +610,14 @@ fn agent_kind_mark(kind: AgentKind) -> impl IntoElement { AgentKind::Claude => ("C", gpui::rgb(0xd97757)), AgentKind::Codex => ("X", gpui::rgb(0x6b7280)), AgentKind::Gemini => ("G", gpui::rgb(0x4285f4)), + AgentKind::Cursor => ("R", gpui::rgb(0xf59e0b)), + AgentKind::Copilot => ("P", gpui::rgb(0x818cf8)), + AgentKind::Amp => ("A", gpui::rgb(0x22c55e)), + AgentKind::Cline => ("L", gpui::rgb(0x38bdf8)), + AgentKind::OpenCode => ("O", gpui::rgb(0x94a3b8)), + AgentKind::Grok => ("K", gpui::rgb(0xe5e7eb)), + AgentKind::Kimi => ("M", gpui::rgb(0xf472b6)), + AgentKind::Other => ("•", gpui::rgb(0x9ca3af)), AgentKind::Unknown => return div().into_any_element(), }; div() From bf0021652bbe8cd46369e8755243829452fc9125 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:48:33 +0000 Subject: [PATCH 05/50] Restore PTY sessions on launch and close GPUI fidelity gaps. Wire DB session restore into the workspace, sync notes when switching profiles, render a real two-column git split, and add keyboard sidebar resize plus file-tree multi-select. Co-authored-by: AkaraChen --- src-gpui/README.md | 2 +- src-gpui/src/app.rs | 366 ++++++++++++++++++++---------- src-gpui/src/backend.rs | 132 +++++------ src-gpui/src/detector.rs | 22 +- src-gpui/src/diff.rs | 126 ++++++++++ src-gpui/src/main.rs | 5 +- src-gpui/src/state.rs | 37 +-- src-gpui/src/ui/file_tree.rs | 62 +++-- src-gpui/src/ui/git.rs | 349 +++++++++++++++++----------- src-gpui/src/ui/shell.rs | 37 ++- src-gpui/src/ui/sidebar.rs | 351 ++++++++++++++-------------- src-gpui/src/ui/workspace.rs | 113 ++++----- src-tauri/crates/model/src/pty.rs | 2 +- 13 files changed, 966 insertions(+), 638 deletions(-) create mode 100644 src-gpui/src/diff.rs diff --git a/src-gpui/README.md b/src-gpui/README.md index b9128c0f..8f5b88cc 100644 --- a/src-gpui/README.md +++ b/src-gpui/README.md @@ -23,6 +23,6 @@ Requires Rust 1.87+ (GPUI). On Linux install `libxkbcommon`, Vulkan, and Wayland ## Surfaces -App sidebar, Home empty state, Profile workspace (top bar, Files/Git/Notes, unified tabs), PTY terminals (vt100, never destroyed on tab switch), file viewer (text / markdown / binary), command palette, Git panel + large diff dialog, all product dialogs, toasts, debug FAB, settings (6 tabs), en/zh i18n from `messages/*.json`. +App sidebar, Home empty state, Profile workspace (top bar, Files/Git/Notes, unified tabs), PTY terminals (vt100 cell grid, never destroyed on tab switch, restored from the session DB on launch, drag-to-PTY paths, clickable links), file viewer (text / markdown / image / archive), file-tree multi-select + inline rename, command palette, Git panel + unified/split two-column diff, all product dialogs, toasts, debug FAB, settings (6 tabs, system font/sound pickers), GitHub updater, filesystem watcher, en/zh i18n from `messages/*.json`. See `docs/ui-inventory.md` for the rewrite spec this crate implements. diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 205873f3..0345abcd 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -2,10 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; use std::time::Duration; -use gpui::{ - div, prelude::*, px, Action, App, Context, Entity, FocusHandle, KeyBinding, Timer, Window, - WindowHandle, -}; +use gpui::{div, prelude::*, px, Action, App, Context, Entity, FocusHandle, KeyBinding, Timer, Window, WindowHandle}; #[derive(Clone, PartialEq, Default, Debug, Action)] #[action(namespace = twocode, no_json)] @@ -34,17 +31,16 @@ pub struct SaveFile; #[derive(Clone, PartialEq, Default, Debug, Action)] #[action(namespace = twocode, no_json)] pub struct CommitChanges; -use gpui_component::input::{Input, InputState}; -use gpui_component::{Root, WindowExt}; use crate::backend::{self, Backend}; use crate::i18n::{self, Locale}; use crate::prefs::{term_theme_by_name, Prefs, ThemePref}; use crate::state::{ - AgentStatus, AppData, ContextMenu, DialogKind, DiffPreviewMode, GitDiffTab, NotesStatus, - OpenFileTab, OverlayState, Route, SettingsTab, SidebarMode, TermSession, ToastKind, TreeNode, - UnifiedTab, Workspace, + AgentStatus, AppData, ContextMenu, DialogKind, DiffPreviewMode, GitDiffTab, NotesStatus, OpenFileTab, OverlayState, + Route, SettingsTab, SidebarMode, TermSession, ToastKind, TreeNode, UnifiedTab, Workspace, }; use crate::ui; +use gpui_component::input::{Input, InputState}; +use gpui_component::{Root, WindowExt}; pub struct Inputs { pub project_name: Entity, @@ -84,12 +80,7 @@ pub struct AppView { impl AppView { pub fn new(backend: Backend, window: &mut Window, cx: &mut Context) -> Self { - fn input( - window: &mut Window, - cx: &mut Context, - placeholder: &str, - multi: bool, - ) -> Entity { + fn input(window: &mut Window, cx: &mut Context, placeholder: &str, multi: bool) -> Entity { let ph = placeholder.to_string(); cx.new(|cx| { let mut state = InputState::new(window, cx).placeholder(ph); @@ -116,6 +107,7 @@ impl AppView { toast_seq: 0, sidebar_error: None, notes_dirty_since: None, + notes_bound_profile: None, }; let inputs = Inputs { @@ -209,9 +201,7 @@ impl AppView { cx.spawn(async move |this, cx| { Timer::after(Duration::from_secs(1)).await; - let accept_beta = this - .update(cx, |app, _| app.data.prefs.accept_beta) - .unwrap_or(false); + let accept_beta = this.update(cx, |app, _| app.data.prefs.accept_beta).unwrap_or(false); let result = crate::updater::check_for_update(accept_beta); let _ = this.update(cx, |app, cx| { app.apply_update_result(result, true); @@ -222,6 +212,7 @@ impl AppView { let mut view = view; view.reload_projects(); + view.restore_all_sessions(); if view.data.projects.is_empty() { view.data.overlay.onboarding = true; } else if let Some((project_id, profile_id)) = view.data.projects.first().and_then(|first| { @@ -320,6 +311,8 @@ impl AppView { files: Vec::new(), active: None, tree: HashMap::new(), + tree_selected: HashSet::new(), + tree_anchor: None, tree_error: None, git_files: Vec::new(), git_included: HashSet::new(), @@ -347,11 +340,7 @@ impl AppView { .map(|e| (e.path, e.status)) .collect(); ws.git_included = ws.git_files.iter().map(|(p, _)| p.clone()).collect(); - ws.pr = self - .backend - .pr_status(&ws.worktree, Some(&ws.branch)) - .ok() - .flatten(); + ws.pr = self.backend.pr_status(&ws.worktree, Some(&ws.branch)).ok().flatten(); if ws.is_default { if let Ok(branch) = self.backend.git_branch(&ws.worktree) { ws.branch = branch; @@ -453,10 +442,7 @@ impl AppView { } else { None }; - let preview_path = preview - .as_ref() - .map(|p| p.file_path.clone()) - .unwrap_or_default(); + let preview_path = preview.as_ref().map(|p| p.file_path.clone()).unwrap_or_default(); let archive_entries = preview .as_ref() .and_then(|p| p.archive_entries.clone()) @@ -482,10 +468,7 @@ impl AppView { content: content.clone(), draft: content.clone(), preview: previewable, - preview_kind: preview - .as_ref() - .map(|p| p.kind.clone()) - .unwrap_or_default(), + preview_kind: preview.as_ref().map(|p| p.kind.clone()).unwrap_or_default(), binary_note: preview.map(|p| p.mime_type).unwrap_or_default(), preview_path, archive_entries, @@ -530,8 +513,7 @@ impl AppView { file.content = draft; file.draft = file.content.clone(); } - self.data - .push_toast(ToastKind::Success, self.t("save"), title); + self.data.push_toast(ToastKind::Success, self.t("save"), title); } Err(err) => self .data @@ -597,8 +579,7 @@ impl AppView { { Ok(id) => { if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { - ws.terminals - .push(TermSession::new(id, title, profile_id.clone())); + ws.terminals.push(TermSession::new(id, title, profile_id.clone())); ws.active = Some(UnifiedTab::Terminal { index: ws.terminals.len() - 1, }); @@ -705,10 +686,179 @@ impl AppView { } } + fn restore_all_sessions(&mut self) { + let Ok(sessions) = self.backend.list_all_sessions() else { + return; + }; + for record in sessions { + let Some(project_id) = self + .data + .projects + .iter() + .find(|p| p.profiles.iter().any(|pr| pr.id == record.profile_id)) + .map(|p| p.id.clone()) + else { + continue; + }; + self.ensure_workspace(&project_id, &record.profile_id); + match self.backend.restore_session(&record) { + Ok((new_id, history)) => { + if let Some(ws) = self.data.workspaces.get_mut(&record.profile_id) { + let mut term = TermSession::new(new_id, record.title.clone(), record.profile_id.clone()); + let _ = term.set_size(record.rows.max(1) as u16, record.cols.max(1) as u16); + if !history.is_empty() { + term.feed(&history); + } + ws.terminals.push(term); + ws.active = Some(UnifiedTab::Terminal { + index: ws.terminals.len() - 1, + }); + } + } + Err(err) => { + tracing::warn!( + error = %err, + session = %record.id, + "failed to restore pty session" + ); + } + } + } + } + + fn sync_notes_input(&mut self, window: &mut Window, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + if self.data.notes_bound_profile.as_deref() == Some(profile_id.as_str()) { + return; + } + if let Some(prev) = self.data.notes_bound_profile.clone() { + if self.data.notes_dirty_since.is_some() { + let notes = self.inputs.notes.read(cx).value().to_string(); + if let Some(ws) = self.data.workspaces.get_mut(&prev) { + ws.notes = notes.clone(); + ws.notes_status = NotesStatus::Saving; + } + match self.backend.update_notes(&prev, ¬es) { + Ok(()) => { + if let Some(ws) = self.data.workspaces.get_mut(&prev) { + ws.notes_status = NotesStatus::Saved; + } + } + Err(_) => { + if let Some(ws) = self.data.workspaces.get_mut(&prev) { + ws.notes_status = NotesStatus::Failed; + } + } + } + self.data.notes_dirty_since = None; + } + } + let notes = self + .data + .workspaces + .get(&profile_id) + .map(|w| w.notes.clone()) + .unwrap_or_default(); + self.inputs.notes.update(cx, |s, cx| { + s.set_value(notes, window, cx); + }); + self.data.notes_bound_profile = Some(profile_id); + } + + pub fn click_tree_path( + &mut self, + path: &str, + is_dir: bool, + multi: bool, + range: bool, + window: &mut Window, + cx: &mut Context, + ) { + let Some(profile) = self.data.current_profile.clone() else { + return; + }; + if range { + let paths = self + .data + .workspaces + .get(&profile) + .map(visible_tree_paths) + .unwrap_or_default(); + let anchor = self + .data + .workspaces + .get(&profile) + .and_then(|w| w.tree_anchor.clone()) + .unwrap_or_else(|| path.to_string()); + if let (Some(a), Some(b)) = ( + paths.iter().position(|p| p == &anchor), + paths.iter().position(|p| p == path), + ) { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + if let Some(ws) = self.data.workspaces.get_mut(&profile) { + ws.tree_selected = paths[lo..=hi].iter().cloned().collect(); + ws.tree_anchor = Some(anchor); + } + } + return; + } + if multi { + if let Some(ws) = self.data.workspaces.get_mut(&profile) { + if !ws.tree_selected.remove(path) { + ws.tree_selected.insert(path.to_string()); + } + ws.tree_anchor = Some(path.to_string()); + } + return; + } + if let Some(ws) = self.data.workspaces.get_mut(&profile) { + ws.tree_selected.clear(); + ws.tree_selected.insert(path.to_string()); + ws.tree_anchor = Some(path.to_string()); + } + if is_dir { + self.toggle_dir(&profile, path); + } else { + self.open_file(&profile, path, window, cx); + } + } + + pub fn nudge_sidebar(&mut self, profile: bool, key: &str) -> bool { + let (min, max) = if profile { + (180.0_f32, 560.0_f32) + } else { + (220.0_f32, 420.0_f32) + }; + let width = if profile { + &mut self.data.prefs.profile_sidebar_width + } else { + &mut self.data.prefs.sidebar_width + }; + let next = match key { + "left" => *width - 16.0, + "right" => *width + 16.0, + "home" => min, + "end" => max, + _ => return false, + } + .clamp(min, max); + if (*width - next).abs() < f32::EPSILON { + return false; + } + *width = next; + self.persist_prefs(); + true + } + fn autosave_notes(&mut self, cx: &mut Context) { let Some(profile_id) = self.data.current_profile.clone() else { return; }; + if self.data.notes_bound_profile.as_deref() != Some(profile_id.as_str()) { + return; + } let live = self.inputs.notes.read(cx).value().to_string(); let Some(ws) = self.data.workspaces.get_mut(&profile_id) else { return; @@ -781,10 +931,7 @@ impl AppView { pub fn apply_update_result(&mut self, result: Result, silent: bool) { match result { Ok(info) if info.available => { - let title = self.tf( - "updateAvailableTitle", - &[("version", &info.latest_version)], - ); + let title = self.tf("updateAvailableTitle", &[("version", &info.latest_version)]); let body = self.tf( "updateAvailableDescription", &[ @@ -792,12 +939,8 @@ impl AppView { ("version", &info.latest_version), ], ); - self.data.push_toast_action( - ToastKind::Info, - title, - body, - Some(crate::state::ToastAction::OpenAbout), - ); + self.data + .push_toast_action(ToastKind::Info, title, body, Some(crate::state::ToastAction::OpenAbout)); } Ok(_) => { if !silent { @@ -810,11 +953,8 @@ impl AppView { } Err(err) => { if !silent { - self.data.push_toast( - ToastKind::Error, - self.t("updateCheckFailedTitle"), - err, - ); + self.data + .push_toast(ToastKind::Error, self.t("updateCheckFailedTitle"), err); } } } @@ -989,11 +1129,8 @@ impl AppView { } Err(err) => { self.data.overlay.dialog_busy = false; - self.data.overlay.delete_warning = Some(format!( - "{}\n{}", - self.t("deleteProfileGitCheckFailedTitle"), - err - )); + self.data.overlay.delete_warning = + Some(format!("{}\n{}", self.t("deleteProfileGitCheckFailedTitle"), err)); } } } @@ -1021,19 +1158,13 @@ impl AppView { self.data.push_toast( ToastKind::Success, self.t("gitCommitSuccessTitle"), - i18n::tf( - self.data.locale, - "gitCommitSuccessDescription", - &[("hash", &hash)], - ), + i18n::tf(self.data.locale, "gitCommitSuccessDescription", &[("hash", &hash)]), ); self.refresh_workspace(&profile_id); } - Err(err) => self.data.push_toast( - ToastKind::Error, - self.t("gitCommitErrorTitle"), - err.to_string(), - ), + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("gitCommitErrorTitle"), err.to_string()), } } @@ -1047,11 +1178,9 @@ impl AppView { .push_toast(ToastKind::Success, self.t("gitPushSuccessTitle"), ""); self.refresh_workspace(&profile_id); } - Err(err) => self.data.push_toast( - ToastKind::Error, - self.t("gitPushErrorTitle"), - err.to_string(), - ), + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("gitPushErrorTitle"), err.to_string()), } } @@ -1064,19 +1193,13 @@ impl AppView { self.data.push_toast( ToastKind::Success, self.t("gitDiscardFileSuccessTitle"), - i18n::tf( - self.data.locale, - "gitDiscardFileSuccessDescription", - &[("file", path)], - ), + i18n::tf(self.data.locale, "gitDiscardFileSuccessDescription", &[("file", path)]), ); self.refresh_workspace(&profile_id); } - Err(err) => self.data.push_toast( - ToastKind::Error, - self.t("gitDiscardFileErrorTitle"), - err.to_string(), - ), + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("gitDiscardFileErrorTitle"), err.to_string()), } } @@ -1124,11 +1247,9 @@ impl AppView { } self.data.overlay.dialog = None; } - Err(err) => self.data.push_toast( - ToastKind::Error, - self.t("gitCheckoutErrorTitle"), - err.to_string(), - ), + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("gitCheckoutErrorTitle"), err.to_string()), } } @@ -1182,12 +1303,7 @@ impl AppView { .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); return; } - if let Some(ws) = self - .data - .workspaces - .values_mut() - .find(|w| w.project_id == project_id) - { + if let Some(ws) = self.data.workspaces.values_mut().find(|w| w.project_id == project_id) { ws.config = config; } self.data.overlay.dialog = None; @@ -1202,10 +1318,7 @@ impl AppView { self.data.overlay.palette_results.clear(); return; } - self.data.overlay.palette_results = self - .backend - .search_files(&profile_id, &q) - .unwrap_or_default(); + self.data.overlay.palette_results = self.backend.search_files(&profile_id, &q).unwrap_or_default(); self.data.overlay.palette_index = 0; } @@ -1214,12 +1327,7 @@ impl AppView { return; }; let ix = self.data.overlay.palette_index; - let path = self - .data - .overlay - .palette_results - .get(ix) - .map(|r| r.path.clone()); + let path = self.data.overlay.palette_results.get(ix).map(|r| r.path.clone()); if let Some(path) = path { self.data.overlay.palette_open = false; self.open_file(&profile_id, &path, window, cx); @@ -1256,11 +1364,8 @@ impl AppView { format!("{parent}/{name}") }; if let Err(err) = self.backend.rename_path(&profile_id, &from, &to) { - self.data.push_toast( - ToastKind::Error, - self.t("fileTreeCreateErrorTitle"), - err.to_string(), - ); + self.data + .push_toast(ToastKind::Error, self.t("fileTreeCreateErrorTitle"), err.to_string()); return; } self.data.overlay.renaming_path = None; @@ -1297,11 +1402,9 @@ impl AppView { self.data.overlay.dialog = Some(DialogKind::ChooseFile); self.data.overlay.fuzzy_files = candidates; } - Err(err) => self.data.push_toast( - ToastKind::Error, - self.t("somethingWentWrong"), - err.to_string(), - ), + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()), } } } @@ -1322,11 +1425,8 @@ impl AppView { name }; if let Err(err) = self.backend.create_path(&profile_id, &name, is_dir) { - self.data.push_toast( - ToastKind::Error, - self.t("fileTreeCreateErrorTitle"), - err.to_string(), - ); + self.data + .push_toast(ToastKind::Error, self.t("fileTreeCreateErrorTitle"), err.to_string()); return; } self.load_tree_root(&profile_id); @@ -1338,11 +1438,8 @@ impl AppView { return; }; if let Err(err) = self.backend.delete_paths(&profile_id, &[path.to_string()]) { - self.data.push_toast( - ToastKind::Error, - self.t("fileTreeDeleteErrorTitle"), - err.to_string(), - ); + self.data + .push_toast(ToastKind::Error, self.t("fileTreeDeleteErrorTitle"), err.to_string()); return; } self.load_tree_root(&profile_id); @@ -1399,7 +1496,11 @@ impl AppView { } pub fn open_pr(&self) { - if let Some(url) = self.data.current_ws().and_then(|w| w.pr.as_ref().map(|p| p.url.clone())) { + if let Some(url) = self + .data + .current_ws() + .and_then(|w| w.pr.as_ref().map(|p| p.url.clone())) + { let _ = open::that(url); } } @@ -1527,19 +1628,34 @@ pub fn extract_file_hunk(diff: &str, path: &str) -> String { } } +fn visible_tree_paths(ws: &Workspace) -> Vec { + let mut out = Vec::new(); + fn walk(ws: &Workspace, path: &str, out: &mut Vec) { + let Some(node) = ws.tree.get(path) else { + return; + }; + for child in &node.children { + out.push(child.clone()); + if ws.tree.get(child).is_some_and(|c| c.is_dir && c.expanded) { + walk(ws, child, out); + } + } + } + walk(ws, "", &mut out); + out +} + impl gpui::Render for AppView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.apply_theme(window, cx); self.sync_pty_size(window); + self.sync_notes_input(window, cx); if self.data.overlay.palette_open { let q = self.inputs.palette.read(cx).value().to_string(); if self.data.overlay.palette_results.is_empty() && !q.is_empty() { self.search_palette(cx); } } - if matches!(self.data.current_ws().map(|w| w.sidebar_mode), Some(SidebarMode::Notes)) { - // keep notes editor mounted - } if let Some(ws) = self.data.current_ws() { if let Some(UnifiedTab::File { index }) = ws.active { if let Some(file) = ws.files.get(index) { diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index ff57242e..33630212 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -2,14 +2,14 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{mpsc, Arc, Mutex}; -use infra::db::{DbPool, init_db}; +use infra::db::{init_db, DbPool}; use infra::pty::{self as pty_infra, PtyReadThreads, PtySessionMap}; use model::error::AppError; use model::filesystem::{FilePreview, FileSearchResult, FileTreeGitStatusEntry}; use model::profile::{Profile, ProfileDeleteCheck}; use model::project::{ - GitBranchInfo, GitCommit, GitDiffSnapshot, GitDiffStats, GitPullRequestStatus, - Project, ProjectConfig, ProjectSidebarLayoutUpdate, ProjectWithProfiles, + GitBranchInfo, GitCommit, GitDiffSnapshot, GitDiffStats, GitPullRequestStatus, Project, ProjectConfig, + ProjectSidebarLayoutUpdate, ProjectWithProfiles, }; use model::project_group::ProjectGroup; use model::pty::{PtyConfig, PtySessionMeta, PtySessionRecord}; @@ -28,9 +28,7 @@ pub struct GpuiPtyEmitter { impl PtyEventEmitter for GpuiPtyEmitter { fn emit_output(&self, session_id: &str, bytes: &[u8]) -> bool { if let Ok(mut map) = self.buffers.lock() { - map.entry(session_id.to_string()) - .or_default() - .extend_from_slice(bytes); + map.entry(session_id.to_string()).or_default().extend_from_slice(bytes); } true } @@ -79,8 +77,7 @@ pub struct Backend { impl Backend { pub fn init() -> Result { let app_data_dir = app_data_dir(); - std::fs::create_dir_all(&app_data_dir) - .map_err(|e| format!("create app data dir: {e}"))?; + std::fs::create_dir_all(&app_data_dir).map_err(|e| format!("create app data dir: {e}"))?; let db = init_db(&app_data_dir)?; service::pty::mark_all_closed(&db); @@ -125,7 +122,10 @@ impl Backend { } } - fn with_db(&self, f: impl FnOnce(&mut diesel::sqlite::SqliteConnection) -> Result) -> Result { + fn with_db( + &self, + f: impl FnOnce(&mut diesel::sqlite::SqliteConnection) -> Result, + ) -> Result { let mut conn = self.db.lock().map_err(|_| AppError::LockError)?; f(&mut conn) } @@ -143,9 +143,7 @@ impl Backend { } pub fn rename_project(&self, id: &str, name: &str) -> Result { - self.with_db(|conn| { - service::project::update(conn, id, Some(name.to_string()), None) - }) + self.with_db(|conn| service::project::update(conn, id, Some(name.to_string()), None)) } pub fn delete_project(&self, id: &str) -> Result<(), AppError> { @@ -156,18 +154,11 @@ impl Backend { self.with_db(|conn| service::project::create_group(conn, name)) } - pub fn assign_to_group( - &self, - project_id: &str, - group_id: Option, - ) -> Result { + pub fn assign_to_group(&self, project_id: &str, group_id: Option) -> Result { self.with_db(|conn| service::project::assign_to_group(conn, project_id, group_id)) } - pub fn update_sidebar_layout( - &self, - updates: Vec, - ) -> Result<(), AppError> { + pub fn update_sidebar_layout(&self, updates: Vec) -> Result<(), AppError> { self.with_db(|conn| service::project::update_sidebar_layout(conn, updates)) } @@ -244,29 +235,17 @@ impl Backend { infra::git::checkout_branch(folder, branch) } - pub fn pr_status( - &self, - folder: &str, - branch: Option<&str>, - ) -> Result, AppError> { + pub fn pr_status(&self, folder: &str, branch: Option<&str>) -> Result, AppError> { service::project::get_pull_request_status_for_folder(folder, branch) } pub fn project_config(&self, project_id: &str) -> Result { - let folder = self.with_db(|conn| { - Ok(repo::project::find_by_id(conn, project_id)?.folder) - })?; + let folder = self.with_db(|conn| Ok(repo::project::find_by_id(conn, project_id)?.folder))?; Ok(infra::config::load_project_config(&folder).unwrap_or_default()) } - pub fn save_project_config( - &self, - project_id: &str, - config: &ProjectConfig, - ) -> Result<(), AppError> { - let folder = self.with_db(|conn| { - Ok(repo::project::find_by_id(conn, project_id)?.folder) - })?; + pub fn save_project_config(&self, project_id: &str, config: &ProjectConfig) -> Result<(), AppError> { + let folder = self.with_db(|conn| Ok(repo::project::find_by_id(conn, project_id)?.folder))?; infra::config::write_project_config(&folder, config) } @@ -279,11 +258,7 @@ impl Backend { .flatten() } - pub fn list_tree_children( - &self, - profile_id: &str, - relative: Option<&str>, - ) -> Result, AppError> { + pub fn list_tree_children(&self, profile_id: &str, relative: Option<&str>) -> Result, AppError> { service::filesystem::list_file_tree_child_paths(&self.db, profile_id, relative) } @@ -308,13 +283,7 @@ impl Backend { let office_cache = self.app_data_dir.join("office-preview"); let _ = std::fs::create_dir_all(&file_cache); let _ = std::fs::create_dir_all(&office_cache); - service::filesystem::get_file_preview( - &self.db, - profile_id, - path, - &file_cache, - &office_cache, - ) + service::filesystem::get_file_preview(&self.db, profile_id, path, &file_cache, &office_cache) } pub fn create_path(&self, profile_id: &str, path: &str, is_dir: bool) -> Result<(), AppError> { @@ -376,12 +345,7 @@ impl Backend { } pub fn close_terminal(&self, session_id: &str) -> Result<(), AppError> { - service::pty::close_session_full( - &self.sessions, - &self.flush_senders, - &self.output_dir, - session_id, - )?; + service::pty::close_session_full(&self.sessions, &self.flush_senders, &self.output_dir, session_id)?; self.with_db(|conn| { repo::pty::mark_closed(conn, session_id); Ok(()) @@ -450,27 +414,14 @@ impl Backend { kind: "project".into(), id: p.id.clone(), group_id: p.group_id.clone(), - sort_order: Some(if p.pinned_at.is_some() { - p.sort_order - } else { - i as i32 - }), - pinned_order: if p.pinned_at.is_some() { - Some(i as i32) - } else { - None - }, + sort_order: Some(if p.pinned_at.is_some() { p.sort_order } else { i as i32 }), + pinned_order: if p.pinned_at.is_some() { Some(i as i32) } else { None }, }) .collect(); self.update_sidebar_layout(updates) } - pub fn drop_project( - &self, - dragged: &str, - target: Option<&str>, - unpin: bool, - ) -> Result<(), AppError> { + pub fn drop_project(&self, dragged: &str, target: Option<&str>, unpin: bool) -> Result<(), AppError> { let project = self.find_project(dragged)?; if unpin { return self.update_sidebar_layout(vec![ProjectSidebarLayoutUpdate { @@ -501,6 +452,35 @@ impl Backend { self.with_db(|conn| service::pty::list_project_sessions(conn, project_id)) } + pub fn list_all_sessions(&self) -> Result, AppError> { + let projects = self.list_projects()?; + let mut sessions = Vec::new(); + for project in projects { + sessions.extend(self.list_sessions(&project.id)?); + } + Ok(sessions) + } + + pub fn restore_session(&self, record: &PtySessionRecord) -> Result<(String, Vec), AppError> { + let ctx = self.pty_ctx(); + let result = service::pty::restore_session( + &ctx, + &record.id, + &PtySessionMeta { + profile_id: record.profile_id.clone(), + title: record.title.clone(), + }, + &PtyConfig { + shell: record.shell.clone(), + cwd: record.cwd.clone(), + rows: record.rows.max(1) as u16, + cols: record.cols.max(1) as u16, + startup_commands: Vec::new(), + }, + )?; + Ok((result.new_session_id, result.history)) + } + pub fn take_watch_events(&self) -> Vec { self.watch_events .lock() @@ -509,11 +489,7 @@ impl Backend { .unwrap_or_default() } - pub fn resolve_file( - &self, - profile_id: &str, - path: &str, - ) -> Result { + pub fn resolve_file(&self, profile_id: &str, path: &str) -> Result { service::filesystem::resolve_terminal_file_path(&self.db, profile_id, path) } @@ -568,8 +544,8 @@ pub fn _channel() -> (mpsc::Sender, mpsc::Receiver) { pub fn is_previewable(path: &str) -> bool { let lower = path.to_ascii_lowercase(); [ - ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".pdf", - ".zip", ".tar", ".gz", ".tgz", ".docx", ".xlsx", ".pptx", + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".pdf", ".zip", ".tar", ".gz", ".tgz", ".docx", + ".xlsx", ".pptx", ] .iter() .any(|ext| lower.ends_with(ext)) diff --git a/src-gpui/src/detector.rs b/src-gpui/src/detector.rs index 300f32ce..41d1eeaf 100644 --- a/src-gpui/src/detector.rs +++ b/src-gpui/src/detector.rs @@ -21,6 +21,16 @@ fn detect_kind(hay: &str) -> AgentKind { ("opencode", AgentKind::OpenCode), ("grok", AgentKind::Grok), ("kimi", AgentKind::Kimi), + ("devin", AgentKind::Devin), + ("droid", AgentKind::Droid), + ("factory droid", AgentKind::Droid), + ("hermes", AgentKind::Hermes), + ("kilo", AgentKind::Kilo), + ("kiro", AgentKind::Kiro), + ("qoder", AgentKind::Qoder), + ("qodercli", AgentKind::Qoder), + ("agy", AgentKind::Agy), + ("pi agent", AgentKind::Pi), ("aider", AgentKind::Other), ]; for (needle, kind) in RULES { @@ -61,9 +71,14 @@ fn is_blocked(hay: &str) -> bool { "esc to cancel", "do you want to continue", "do you want to proceed", + "run a dynamic workflow?", + "waiting for permission", + "allow this command", + "bash command", "[y/n]", "yes (y)", "would you like to", + "enter to set as default", ]; PHRASES.iter().any(|p| hay.contains(p)) } @@ -93,16 +108,13 @@ fn is_idle_prompt(hay: &str) -> bool { } fn has_braille_spinner(hay: &str) -> bool { - hay.chars() - .any(|c| ('\u{2800}'..='\u{28FF}').contains(&c)) + hay.chars().any(|c| ('\u{2800}'..='\u{28FF}').contains(&c)) } pub fn clickable_tokens(screen: &str) -> Vec { let mut out = Vec::new(); for token in screen.split_whitespace() { - let clean = token.trim_matches(|c: char| { - matches!(c, ',' | ';' | ')' | '(' | '[' | ']' | '"' | '\'' | '`') - }); + let clean = token.trim_matches(|c: char| matches!(c, ',' | ';' | ')' | '(' | '[' | ']' | '"' | '\'' | '`')); if clean.starts_with("http://") || clean.starts_with("https://") { out.push(Clickable::Url(clean.to_string())); } else if looks_like_path(clean) { diff --git a/src-gpui/src/diff.rs b/src-gpui/src/diff.rs new file mode 100644 index 00000000..a876ff52 --- /dev/null +++ b/src-gpui/src/diff.rs @@ -0,0 +1,126 @@ +/// A single side of a split-diff row. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DiffLineKind { + Context, + Add, + Del, + Header, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SplitRow { + pub left: Option<(DiffLineKind, String)>, + pub right: Option<(DiffLineKind, String)>, +} + +/// Pair consecutive `-` / `+` runs in a unified diff into two-column rows. +/// Context and hunk headers appear on both sides; deletions stay left; additions stay right. +pub fn split_rows(diff: &str) -> Vec { + let mut rows = Vec::new(); + let mut dels = Vec::new(); + let mut adds = Vec::new(); + + fn flush(rows: &mut Vec, dels: &mut Vec, adds: &mut Vec) { + let n = dels.len().max(adds.len()); + for i in 0..n { + rows.push(SplitRow { + left: dels.get(i).cloned().map(|s| (DiffLineKind::Del, s)), + right: adds.get(i).cloned().map(|s| (DiffLineKind::Add, s)), + }); + } + dels.clear(); + adds.clear(); + } + + for line in diff.lines() { + if is_header(line) { + flush(&mut rows, &mut dels, &mut adds); + let text = line.to_string(); + rows.push(SplitRow { + left: Some((DiffLineKind::Header, text.clone())), + right: Some((DiffLineKind::Header, text)), + }); + } else if line.starts_with('+') { + adds.push(line.to_string()); + } else if line.starts_with('-') { + dels.push(line.to_string()); + } else { + flush(&mut rows, &mut dels, &mut adds); + let text = line.to_string(); + rows.push(SplitRow { + left: Some((DiffLineKind::Context, text.clone())), + right: Some((DiffLineKind::Context, text)), + }); + } + } + flush(&mut rows, &mut dels, &mut adds); + rows +} + +fn is_header(line: &str) -> bool { + line.starts_with("diff ") + || line.starts_with("index ") + || line.starts_with("---") + || line.starts_with("+++") + || line.starts_with("@@") + || line.starts_with("new file") + || line.starts_with("deleted file") + || line.starts_with("rename ") + || line.starts_with("similarity ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pairs_replacement_on_one_row() { + let rows = split_rows("-old\n+new\n"); + assert_eq!( + rows, + vec![SplitRow { + left: Some((DiffLineKind::Del, "-old".into())), + right: Some((DiffLineKind::Add, "+new".into())), + }] + ); + } + + #[test] + fn context_appears_on_both_sides() { + let rows = split_rows(" keep\n"); + assert_eq!( + rows, + vec![SplitRow { + left: Some((DiffLineKind::Context, " keep".into())), + right: Some((DiffLineKind::Context, " keep".into())), + }] + ); + } + + #[test] + fn unmatched_deletion_leaves_right_empty() { + let rows = split_rows("-gone\n keep\n"); + assert_eq!( + rows, + vec![ + SplitRow { + left: Some((DiffLineKind::Del, "-gone".into())), + right: None, + }, + SplitRow { + left: Some((DiffLineKind::Context, " keep".into())), + right: Some((DiffLineKind::Context, " keep".into())), + }, + ] + ); + } + + #[test] + fn hunk_header_spans_both_columns() { + let rows = split_rows("@@ -1,2 +1,2 @@\n-a\n+b\n"); + assert_eq!(rows[0].left.as_ref().unwrap().0, DiffLineKind::Header); + assert_eq!(rows[0].right.as_ref().unwrap().0, DiffLineKind::Header); + assert_eq!(rows[1].left.as_ref().unwrap().0, DiffLineKind::Del); + assert_eq!(rows[1].right.as_ref().unwrap().0, DiffLineKind::Add); + } +} diff --git a/src-gpui/src/main.rs b/src-gpui/src/main.rs index b2385cee..c8ca4a63 100644 --- a/src-gpui/src/main.rs +++ b/src-gpui/src/main.rs @@ -1,6 +1,7 @@ mod app; mod backend; mod detector; +mod diff; mod i18n; mod platform; mod prefs; @@ -8,9 +9,7 @@ mod state; mod ui; mod updater; -use gpui::{ - point, prelude::*, px, size, Application, Bounds, TitlebarOptions, WindowBounds, WindowOptions, -}; +use gpui::{point, prelude::*, px, size, Application, Bounds, TitlebarOptions, WindowBounds, WindowOptions}; use gpui_component::Root; use crate::app::AppView; diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index d6e9805c..59546a3d 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -3,7 +3,9 @@ use std::time::Instant; use model::filesystem::FileSearchResult; use model::profile::Profile; -use model::project::{GitBranchInfo, GitCommit, GitDiffStats, GitPullRequestStatus, ProjectConfig, ProjectWithProfiles}; +use model::project::{ + GitBranchInfo, GitCommit, GitDiffStats, GitPullRequestStatus, ProjectConfig, ProjectWithProfiles, +}; use model::project_group::ProjectGroup; use crate::i18n::Locale; @@ -72,6 +74,14 @@ pub enum AgentKind { OpenCode, Grok, Kimi, + Devin, + Droid, + Hermes, + Kilo, + Kiro, + Pi, + Qoder, + Agy, Other, } @@ -256,6 +266,8 @@ pub struct Workspace { pub files: Vec, pub active: Option, pub tree: HashMap, + pub tree_selected: HashSet, + pub tree_anchor: Option, pub tree_error: Option, pub git_files: Vec<(String, String)>, pub git_included: HashSet, @@ -335,6 +347,7 @@ pub struct OverlayState { pub project_settings_tab: usize, pub sidebar_drag: Option<(f32, f32)>, pub profile_sidebar_drag: Option<(f32, f32)>, + pub sidebar_resize_focus: Option, pub drag_project: Option, pub drag_file: Option, pub renaming_path: Option, @@ -355,13 +368,12 @@ pub struct AppData { pub sidebar_error: Option, pub locale: Locale, pub notes_dirty_since: Option, + pub notes_bound_profile: Option, } impl AppData { pub fn current_ws(&self) -> Option<&Workspace> { - self.current_profile - .as_ref() - .and_then(|id| self.workspaces.get(id)) + self.current_profile.as_ref().and_then(|id| self.workspaces.get(id)) } pub fn current_ws_mut(&mut self) -> Option<&mut Workspace> { @@ -370,16 +382,13 @@ impl AppData { } pub fn default_profile_of(&self, project_id: &str) -> Option { - self.projects - .iter() - .find(|p| p.id == project_id) - .and_then(|p| { - p.profiles - .iter() - .find(|pr| pr.is_default) - .cloned() - .or_else(|| p.profiles.first().cloned()) - }) + self.projects.iter().find(|p| p.id == project_id).and_then(|p| { + p.profiles + .iter() + .find(|pr| pr.is_default) + .cloned() + .or_else(|| p.profiles.first().cloned()) + }) } pub fn project(&self, id: &str) -> Option<&ProjectWithProfiles> { diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index e5e853ff..e3b8342a 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -36,13 +36,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) } }) .when_some(err, |el, err| { - el.child( - div() - .text_xs() - .text_color(theme.danger) - .opacity(0.8) - .child(err), - ) + el.child(div().text_xs().text_color(theme.danger).opacity(0.8).child(err)) }) .child(match root { None => div() @@ -56,9 +50,11 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .child(app.t("fileTreeEmptyDirectory")) .into_any_element(), Some(root) => v_flex() - .children(root.children.iter().map(|path| { - node_view(app, path, 0, &profile, window, cx) - })) + .children( + root.children + .iter() + .map(|path| node_view(app, path, 0, &profile, window, cx)), + ) .into_any_element(), }) .into_any_element() @@ -84,8 +80,8 @@ fn node_view( .map(|(_, s)| s.clone()); let indent = px(12. * depth as f32 + 4.); let path_owned = path.to_string(); - let profile_owned = profile.to_string(); let renaming = app.data.overlay.renaming_path.as_deref() == Some(path); + let selected = app.data.current_ws().is_some_and(|w| w.tree_selected.contains(path)); v_flex() .child( @@ -97,22 +93,19 @@ fn node_view( .py_0p5() .rounded_md() .gap_1() + .when(selected, |el| el.bg(theme.muted)) .hover(|el| el.bg(theme.muted)) .on_click({ let view = view.clone(); let path = path_owned.clone(); - let profile = profile_owned.clone(); let is_dir = node.is_dir; - move |_, window, cx| { + move |ev, window, cx| { view.update(cx, |app, cx| { if app.data.overlay.renaming_path.as_deref() == Some(path.as_str()) { return; } - if is_dir { - app.toggle_dir(&profile, &path); - } else { - app.open_file(&profile, &path, window, cx); - } + let mods = ev.modifiers(); + app.click_tree_path(&path, is_dir, mods.platform || mods.control, mods.shift, window, cx); cx.notify(); }); } @@ -144,15 +137,18 @@ fn node_view( }); } }) - .child(Icon::new(if node.is_dir { - if node.expanded { - IconName::FolderOpen + .child( + Icon::new(if node.is_dir { + if node.expanded { + IconName::FolderOpen + } else { + IconName::Folder + } } else { - IconName::Folder - } - } else { - IconName::File - }).w(px(13.))) + IconName::File + }) + .w(px(13.)), + ) .child(if renaming { h_flex() .flex_1() @@ -196,19 +192,17 @@ fn node_view( el.child( div() .text_xs() - .text_color(if st.contains('D') { - theme.danger - } else { - theme.success - }) + .text_color(if st.contains('D') { theme.danger } else { theme.success }) .child(crate::app::file_status_badge(&st)), ) }), ) .when(node.is_dir && node.expanded, |el| { - el.children(node.children.iter().map(|child| { - node_view(app, child, depth + 1, profile, window, cx) - })) + el.children( + node.children + .iter() + .map(|child| node_view(app, child, depth + 1, profile, window, cx)), + ) }) .into_any_element() } diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index e617b52f..1695af74 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -1,11 +1,12 @@ use gpui::{div, prelude::*, px, rgb, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{Disableable, Selectable}; use gpui_component::checkbox::Checkbox; use gpui_component::input::Input; use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{Disableable, Selectable}; use crate::app::{extract_file_hunk, file_status_badge, AppView}; +use crate::diff::{self, DiffLineKind}; use crate::state::{DiffPreviewMode, GitDiffTab}; pub fn render_panel(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -31,28 +32,30 @@ pub fn render_panel(app: &mut AppView, _window: &mut Window, cx: &mut Context impl IntoElement { let view = cx.entity(); let theme = cx.theme().clone(); + let summary_empty = app.inputs.commit_summary.read(cx).value().trim().is_empty(); v_flex() .id("commit-composer") .w_full() @@ -198,14 +208,17 @@ fn commit_composer( h_flex() .justify_between() .child( - div().text_xs().text_color(theme.muted_foreground).child(crate::i18n::tf( - app.data.locale, - "gitCommitIncludedCount", - &[ - ("includedCount", &included.to_string()), - ("totalCount", &total.to_string()), - ], - )), + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(crate::i18n::tf( + app.data.locale, + "gitCommitIncludedCount", + &[ + ("includedCount", &included.to_string()), + ("totalCount", &total.to_string()), + ], + )), ) .child( h_flex() @@ -220,8 +233,7 @@ fn commit_composer( move |_, _, cx| { view.update(cx, |app, cx| { if let Some(ws) = app.data.current_ws_mut() { - ws.git_included = - ws.git_files.iter().map(|(p, _)| p.clone()).collect(); + ws.git_included = ws.git_files.iter().map(|(p, _)| p.clone()).collect(); } cx.notify(); }); @@ -255,7 +267,7 @@ fn commit_composer( .primary() .small() .label(app.t("gitCommitButton")) - .disabled(included == 0) + .disabled(included == 0 || summary_empty) .on_click({ let view = view.clone(); move |_, _, cx| { @@ -311,11 +323,7 @@ pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Cont .unwrap_or_else(|| "main".into()); let tab = app.data.overlay.git_diff_tab; let mode = app.data.overlay.git_diff_mode; - let files = app - .data - .current_ws() - .map(|w| w.git_files.clone()) - .unwrap_or_default(); + let files = app.data.current_ws().map(|w| w.git_files.clone()).unwrap_or_default(); let selected = app.data.overlay.git_diff_file.clone(); let diff = selected .as_ref() @@ -435,8 +443,20 @@ pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Cont .px_2() .py_1() .gap_2() - .child(tab_btn("changes", app.t("changes"), tab == GitDiffTab::Changes, GitDiffTab::Changes, &view)) - .child(tab_btn("history", app.t("history"), tab == GitDiffTab::History, GitDiffTab::History, &view)), + .child(tab_btn( + "changes", + app.t("changes"), + tab == GitDiffTab::Changes, + GitDiffTab::Changes, + &view, + )) + .child(tab_btn( + "history", + app.t("history"), + tab == GitDiffTab::History, + GitDiffTab::History, + &view, + )), ) .child(if tab == GitDiffTab::Changes { v_flex() @@ -448,7 +468,17 @@ pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Cont v_flex() .flex_1() .children(files.iter().map(|(p, s)| { - file_row(app, p, s, app.data.current_ws().map(|w| w.git_included.contains(p)).unwrap_or(false), false, cx) + file_row( + app, + p, + s, + app.data + .current_ws() + .map(|w| w.git_included.contains(p)) + .unwrap_or(false), + false, + cx, + ) })) .into_any_element() }) @@ -464,41 +494,36 @@ pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Cont history_pane(app, cx).into_any_element() }), ) - .child( - v_flex() - .flex_1() - .min_w_0() - .h_full() - .p_3() - .child(if selected.is_none() && tab == GitDiffTab::Changes { - div() - .text_color(theme.muted_foreground) - .child(app.t("selectFileToView")) - .into_any_element() - } else if large { - v_flex() - .gap_2() - .child(div().font_semibold().child(app.t("gitDiffLargeGuardrailTitle"))) - .child(div().text_sm().child(app.t("gitDiffLargeGuardrailDescription"))) - .child( - Button::new("reveal-large") - .label(app.t("gitDiffLargeGuardrailReveal")) - .on_click({ - let view = view.clone(); - let path = selected.clone().unwrap_or_default(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.git_large_revealed.insert(path.clone()); - cx.notify(); - }); - } - }), - ) - .into_any_element() - } else { - diff_view(&diff, mode, theme.muted_foreground).into_any_element() - }), - ), + .child(v_flex().flex_1().min_w_0().h_full().p_3().child( + if selected.is_none() && tab == GitDiffTab::Changes { + div() + .text_color(theme.muted_foreground) + .child(app.t("selectFileToView")) + .into_any_element() + } else if large { + v_flex() + .gap_2() + .child(div().font_semibold().child(app.t("gitDiffLargeGuardrailTitle"))) + .child(div().text_sm().child(app.t("gitDiffLargeGuardrailDescription"))) + .child( + Button::new("reveal-large") + .label(app.t("gitDiffLargeGuardrailReveal")) + .on_click({ + let view = view.clone(); + let path = selected.clone().unwrap_or_default(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_large_revealed.insert(path.clone()); + cx.notify(); + }); + } + }), + ) + .into_any_element() + } else { + diff_view(&diff, mode, theme.muted_foreground).into_any_element() + }, + )), ), ) .into_any_element() @@ -574,60 +599,110 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { if app.data.overlay.git_commits.is_empty() { return div().p_4().child(app.t("noCommitsFound")).into_any_element(); } - v_flex().children(app.data.overlay.git_commits.iter().map(|c| { - let hash = c.hash.clone(); - v_flex() - .id(crate::ui::eid(format!("commit-{hash}"))) - .px_3() - .py_2() - .gap_1() - .hover(|el| el.bg(theme.muted)) - .on_click({ - let view = view.clone(); - let hash = hash.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.select_commit(&hash); - cx.notify(); - }); - } - }) - .child(div().font_medium().text_sm().child(c.message.clone())) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(format!("{} · {}", c.author.name, &hash[..hash.len().min(7)])), - ) - })).into_any_element() + v_flex() + .children(app.data.overlay.git_commits.iter().map(|c| { + let hash = c.hash.clone(); + v_flex() + .id(crate::ui::eid(format!("commit-{hash}"))) + .px_3() + .py_2() + .gap_1() + .hover(|el| el.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let hash = hash.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.select_commit(&hash); + cx.notify(); + }); + } + }) + .child(div().font_medium().text_sm().child(c.message.clone())) + .child(div().text_xs().text_color(theme.muted_foreground).child(format!( + "{} · {}", + c.author.name, + &hash[..hash.len().min(7)] + ))) + })) + .into_any_element() } fn diff_view(diff: &str, mode: DiffPreviewMode, muted: gpui::Hsla) -> impl IntoElement { if diff.trim().is_empty() { return div().text_color(muted).child("").into_any_element(); } + if mode == DiffPreviewMode::Split { + return split_diff_view(diff, muted).into_any_element(); + } v_flex() .id("diff-lines") .gap_0() .font_family("monospace") .text_sm() - .children(diff.lines().map(|line| { - let (bg, color) = if line.starts_with('+') && !line.starts_with("+++") { - (gpui::hsla(0.38, 0.6, 0.4, 0.15), gpui::hsla(0.38, 0.7, 0.45, 1.)) - } else if line.starts_with('-') && !line.starts_with("---") { - (gpui::hsla(0.02, 0.7, 0.5, 0.15), gpui::hsla(0.02, 0.75, 0.5, 1.)) - } else { - (gpui::hsla(0., 0., 0., 0.), muted) - }; - div() - .px_2() - .bg(bg) - .text_color(color) - .child(if mode == DiffPreviewMode::Split && (line.starts_with('+') || line.starts_with('-')) { - format!("{} │ {}", if line.starts_with('+') { " " } else { line }, if line.starts_with('+') { line } else { " " }) - } else { - line.to_string() - }) - })) + .children(diff.lines().map(|line| unified_line(line, muted))) .into_any_element() } + +fn unified_line(line: &str, muted: gpui::Hsla) -> impl IntoElement { + let (bg, color) = line_colors(line, muted); + div().px_2().bg(bg).text_color(color).child(line.to_string()) +} + +fn line_colors(line: &str, muted: gpui::Hsla) -> (gpui::Hsla, gpui::Hsla) { + if line.starts_with('+') && !line.starts_with("+++") { + (gpui::hsla(0.38, 0.6, 0.4, 0.15), gpui::hsla(0.38, 0.7, 0.45, 1.)) + } else if line.starts_with('-') && !line.starts_with("---") { + (gpui::hsla(0.02, 0.7, 0.5, 0.15), gpui::hsla(0.02, 0.75, 0.5, 1.)) + } else { + (gpui::hsla(0., 0., 0., 0.), muted) + } +} + +fn kind_colors(kind: DiffLineKind, muted: gpui::Hsla) -> (gpui::Hsla, gpui::Hsla) { + match kind { + DiffLineKind::Add => (gpui::hsla(0.38, 0.6, 0.4, 0.15), gpui::hsla(0.38, 0.7, 0.45, 1.)), + DiffLineKind::Del => (gpui::hsla(0.02, 0.7, 0.5, 0.15), gpui::hsla(0.02, 0.75, 0.5, 1.)), + DiffLineKind::Header => (gpui::hsla(0., 0., 0., 0.04), muted), + DiffLineKind::Context => (gpui::hsla(0., 0., 0., 0.), muted), + } +} + +fn split_diff_view(diff: &str, muted: gpui::Hsla) -> impl IntoElement { + v_flex() + .id("diff-split") + .gap_0() + .font_family("monospace") + .text_sm() + .children(diff::split_rows(diff).into_iter().enumerate().map(|(ix, row)| { + h_flex() + .id(crate::ui::eid(format!("split-row-{ix}"))) + .w_full() + .child(split_cell(row.left, muted, ix, "l")) + .child(div().w(px(1.)).h_full().bg(gpui::hsla(0., 0., 0.5, 0.2))) + .child(split_cell(row.right, muted, ix, "r")) + })) +} + +fn split_cell( + cell: Option<(DiffLineKind, String)>, + muted: gpui::Hsla, + ix: usize, + side: &'static str, +) -> impl IntoElement { + let (kind, text) = match cell { + Some((kind, text)) => (Some(kind), text), + None => (None, String::new()), + }; + let (bg, color) = kind + .map(|k| kind_colors(k, muted)) + .unwrap_or((gpui::hsla(0., 0., 0., 0.), muted)); + div() + .id(crate::ui::eid(format!("split-{side}-{ix}"))) + .flex_1() + .min_w_0() + .px_2() + .bg(bg) + .text_color(color) + .child(if text.is_empty() { " ".to_string() } else { text }) +} diff --git a/src-gpui/src/ui/shell.rs b/src-gpui/src/ui/shell.rs index ca78e15d..0597c22d 100644 --- a/src-gpui/src/ui/shell.rs +++ b/src-gpui/src/ui/shell.rs @@ -1,4 +1,4 @@ -use gpui::{div, prelude::*, px, rgb, Context, MouseButton, Window}; +use gpui::{div, prelude::*, px, rgb, Context, KeyDownEvent, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{ActiveTheme, Sizable, StyledExt}; @@ -25,8 +25,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) changed = true; } if let Some((start_x, start_w)) = app.data.overlay.profile_sidebar_drag { - app.data.prefs.profile_sidebar_width = - (start_w + x - start_x).clamp(180.0, 560.0); + app.data.prefs.profile_sidebar_width = (start_w + x - start_x).clamp(180.0, 560.0); changed = true; } if changed { @@ -35,6 +34,18 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }); } }) + .on_key_down({ + let view = view.clone(); + move |ev: &KeyDownEvent, _, cx| { + view.update(cx, |app, cx| { + if let Some(profile) = app.data.overlay.sidebar_resize_focus { + if app.nudge_sidebar(profile, ev.keystroke.key.as_str()) { + cx.notify(); + } + } + }); + } + }) .on_mouse_up(MouseButton::Left, { let view = view.clone(); move |_, _, cx| { @@ -72,11 +83,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .child(toasts(app, cx)) } -fn home_or_empty( - app: &mut AppView, - window: &mut Window, - cx: &mut Context, -) -> gpui::AnyElement { +fn home_or_empty(app: &mut AppView, window: &mut Window, cx: &mut Context) -> gpui::AnyElement { crate::ui::home::render(app, window, cx).into_any_element() } @@ -104,12 +111,7 @@ fn toasts(app: &AppView, cx: &mut Context) -> impl IntoElement { .border_color(theme.border) .bg(bg) .shadow_md() - .child( - div() - .text_sm() - .font_semibold() - .child(toast.title.clone()), - ) + .child(div().text_sm().font_semibold().child(toast.title.clone())) .when(!toast.body.is_empty(), |el| { el.child( div() @@ -149,12 +151,7 @@ fn window_controls() -> impl IntoElement { .child(win_btn("close", "×", true, |window| window.remove_window())) } -fn win_btn( - id: &'static str, - label: &'static str, - close: bool, - on: impl Fn(&mut Window) + 'static, -) -> impl IntoElement { +fn win_btn(id: &'static str, label: &'static str, close: bool, on: impl Fn(&mut Window) + 'static) -> impl IntoElement { div() .id(id) .h(px(28.)) diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index f323e733..442025b2 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -1,7 +1,7 @@ -use gpui::{div, prelude::*, px, Context, MouseButton, Window}; +use gpui::{div, prelude::*, px, Context, KeyDownEvent, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{Disableable, Selectable}; use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::backend; @@ -24,131 +24,136 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .relative() .h_full() .child( - v_flex() - .id("app-sidebar") - .w(px(width)) - .h_full() - .bg(theme.sidebar) - .border_r_1() - .border_color(theme.border) - .child( - h_flex() - .id("sidebar-header") - .w_full() - .pt(pad_top) - .px_3() - .pb_2() - .justify_between() + v_flex() + .id("app-sidebar") + .w(px(width)) + .h_full() + .bg(theme.sidebar) + .border_r_1() + .border_color(theme.border) .child( - div() - .font_semibold() - .text_sm() - .child("2Code"), + h_flex() + .id("sidebar-header") + .w_full() + .pt(pad_top) + .px_3() + .pb_2() + .justify_between() + .child(div().font_semibold().text_sm().child("2Code")) + .child( + Button::new("collapse-sidebar") + .ghost() + .xsmall() + .icon(IconName::PanelLeftClose) + .tooltip(app.t("collapseSidebar")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.prefs.sidebar_collapsed = true; + app.persist_prefs(); + cx.notify(); + }); + } + }), + ), ) .child( - Button::new("collapse-sidebar") - .ghost() - .xsmall() - .icon(IconName::PanelLeftClose) - .tooltip(app.t("collapseSidebar")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.prefs.sidebar_collapsed = true; - app.persist_prefs(); - cx.notify(); - }); - } - }), - ), - ) - .child( - v_flex() - .id("sidebar-content") - .flex_1() - .min_h_0() - .px_2() - .py_1() - .gap_1() - .overflow_hidden() - .when(!has_projects, |el| { - el.child(nav_row( - "home-row", - IconName::Inbox, - app.t("home"), - app.data.route == Route::Home, - { - let view = view.clone(); - move |cx| { - view.update(cx, |app, cx| { - app.data.route = Route::Home; - cx.notify(); - }); - } - }, - )) - }) - .when_some(app.data.sidebar_error.clone(), |el, err| { - el.child( - v_flex() - .p_4() - .gap_2() - .child( - div() - .text_color(theme.danger) - .font_semibold() - .child(app.t("somethingWentWrong")), + v_flex() + .id("sidebar-content") + .flex_1() + .min_h_0() + .px_2() + .py_1() + .gap_1() + .overflow_hidden() + .when(!has_projects, |el| { + el.child(nav_row( + "home-row", + IconName::Inbox, + app.t("home"), + app.data.route == Route::Home, + { + let view = view.clone(); + move |cx| { + view.update(cx, |app, cx| { + app.data.route = Route::Home; + cx.notify(); + }); + } + }, + )) + }) + .when_some(app.data.sidebar_error.clone(), |el, err| { + el.child( + v_flex() + .p_4() + .gap_2() + .child( + div() + .text_color(theme.danger) + .font_semibold() + .child(app.t("somethingWentWrong")), + ) + .child(div().text_xs().child(err)) + .child( + Button::new("sidebar-retry") + .xsmall() + .label(app.t("tryAgain")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.reload_projects(); + cx.notify(); + }); + } + }), + ), ) - .child(div().text_xs().child(err)) - .child( - Button::new("sidebar-retry") - .xsmall() - .label(app.t("tryAgain")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.reload_projects(); - cx.notify(); - }); - } - }), - ), - ) - }) - .child(project_sections(app, window, cx)), - ) - .child( - h_flex() - .id("sidebar-footer") - .w_full() - .px_2() - .py_2() - .border_t_1() - .border_color(theme.border) + }) + .child(project_sections(app, window, cx)), + ) .child( - Button::new("open-settings") - .ghost() - .small() - .icon(IconName::Settings) - .label(app.t("settings")) - .on_click({ - let view = view.clone(); - move |_, window, cx| { - view.update(cx, |app, cx| { - settings::open_settings_window(app, window, cx); - }); - } - }), + h_flex() + .id("sidebar-footer") + .w_full() + .px_2() + .py_2() + .border_t_1() + .border_color(theme.border) + .child( + Button::new("open-settings") + .ghost() + .small() + .icon(IconName::Settings) + .label(app.t("settings")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + settings::open_settings_window(app, window, cx); + }); + } + }), + ), ), ) - ) - .child(resize_handle("app-sidebar-resize", view, false)) + .child(resize_handle( + "app-sidebar-resize", + view, + false, + app.data.overlay.sidebar_resize_focus == Some(false), + )) .into_any_element() } -pub(crate) fn resize_handle(id: &'static str, view: gpui::Entity, profile: bool) -> impl IntoElement { +pub(crate) fn resize_handle( + id: &'static str, + view: gpui::Entity, + profile: bool, + focused: bool, +) -> impl IntoElement { div() .id(id) .absolute() @@ -156,26 +161,37 @@ pub(crate) fn resize_handle(id: &'static str, view: gpui::Entity, profi .right_0() .w(px(8.)) .h_full() + .tab_index(0) .cursor(gpui::CursorStyle::ResizeColumn) - .on_mouse_down(MouseButton::Left, move |ev, _, cx| { - view.update(cx, |app, cx| { - let start = f32::from(ev.position.x); - if profile { - app.data.overlay.profile_sidebar_drag = - Some((start, app.data.prefs.profile_sidebar_width)); - } else { - app.data.overlay.sidebar_drag = Some((start, app.data.prefs.sidebar_width)); - } - cx.notify(); - }); + .when(focused, |el| el.bg(gpui::hsla(0., 0., 0.5, 0.3))) + .on_mouse_down(MouseButton::Left, { + let view = view.clone(); + move |ev, _, cx| { + view.update(cx, |app, cx| { + let start = f32::from(ev.position.x); + app.data.overlay.sidebar_resize_focus = Some(profile); + if profile { + app.data.overlay.profile_sidebar_drag = Some((start, app.data.prefs.profile_sidebar_width)); + } else { + app.data.overlay.sidebar_drag = Some((start, app.data.prefs.sidebar_width)); + } + cx.notify(); + }); + } + }) + .on_key_down({ + let view = view.clone(); + move |ev: &KeyDownEvent, _, cx| { + view.update(cx, |app, cx| { + if app.nudge_sidebar(profile, ev.keystroke.key.as_str()) { + cx.notify(); + } + }); + } }) } -fn project_sections( - app: &mut AppView, - _window: &mut Window, - cx: &mut Context, -) -> impl IntoElement { +fn project_sections(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); let theme = cx.theme().clone(); let pinned: Vec<_> = app @@ -199,9 +215,9 @@ fn project_sections( .gap_2() .when(!pinned.is_empty() || app.data.overlay.sort_mode, |el| { el.child(section_label(&app.t("pinnedProjects"))).child( - v_flex().gap_1().children(pinned.into_iter().map(|p| { - project_row(app, &p, cx) - })), + v_flex() + .gap_1() + .children(pinned.into_iter().map(|p| project_row(app, &p, cx))), ) }) .child( @@ -299,11 +315,14 @@ fn project_sections( }); } }) - .child(Icon::new(if collapsed { - IconName::ChevronRight - } else { - IconName::ChevronDown - }).w(px(12.))) + .child( + Icon::new(if collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }) + .w(px(12.)), + ) .child(div().flex_1().text_sm().child(group.name.clone())) .child( div() @@ -375,12 +394,7 @@ fn has_any_projects(app: &AppView) -> bool { } fn section_label(text: &str) -> impl IntoElement { - div() - .px_2() - .pt_2() - .text_xs() - .font_medium() - .child(text.to_string()) + div().px_2().pt_2().text_xs().font_medium().child(text.to_string()) } fn project_row( @@ -392,9 +406,7 @@ fn project_row( let view = cx.entity(); let selected = app.data.current_project.as_deref() == Some(project.id.as_str()); let extras: Vec<_> = project.profiles.iter().filter(|p| !p.is_default).cloned().collect(); - let expanded = app.data.overlay.expanded_projects.contains(&project.id) - || extras.is_empty() - || selected; + let expanded = app.data.overlay.expanded_projects.contains(&project.id) || extras.is_empty() || selected; let agent = app.agent_for_project(&project.id); let letter = project .name @@ -403,11 +415,7 @@ fn project_row( .map(|c| c.to_ascii_uppercase()) .unwrap_or('?'); let id = project.id.clone(); - let default_profile = project - .profiles - .iter() - .find(|p| p.is_default) - .map(|p| p.id.clone()); + let default_profile = project.profiles.iter().find(|p| p.is_default).map(|p| p.id.clone()); v_flex() .id(crate::ui::eid(format!("proj-{}", project.id))) @@ -467,8 +475,11 @@ fn project_row( let id = id.clone(); move |ev, _, cx| { view.update(cx, |app, cx| { - app.data.overlay.context_menu = - Some((ContextMenu::Project { id: id.clone() }, ev.position.x.into(), ev.position.y.into())); + app.data.overlay.context_menu = Some(( + ContextMenu::Project { id: id.clone() }, + ev.position.x.into(), + ev.position.y.into(), + )); cx.notify(); }); } @@ -573,11 +584,14 @@ fn project_row( ) }) .when(!extras.is_empty(), |el| { - el.child(Icon::new(if expanded { - IconName::ChevronDown - } else { - IconName::ChevronRight - }).w(px(12.))) + el.child( + Icon::new(if expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }) + .w(px(12.)), + ) }), ) .when(expanded, |el| { @@ -635,11 +649,14 @@ fn project_row( }); } }) - .child(Icon::new(if profile.is_default { - IconName::SquareTerminal - } else { - IconName::GitHub - }).w(px(14.))) + .child( + Icon::new(if profile.is_default { + IconName::SquareTerminal + } else { + IconName::GitHub + }) + .w(px(14.)), + ) .child(div().flex_1().text_sm().child(label)) .child(agent_dot(app.agent_for_profile(&profile.id))) })) diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 668f9030..8f258542 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -1,8 +1,8 @@ use gpui::{div, prelude::*, px, rgb, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{Disableable, Selectable}; use gpui_component::tab::Tab; use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::state::{AgentKind, DialogKind, SidebarMode, UnifiedTab}; @@ -49,11 +49,7 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> } else { px(16.) }; - let pad_right = if cfg!(target_os = "windows") { - px(118.) - } else { - px(16.) - }; + let pad_right = if cfg!(target_os = "windows") { px(118.) } else { px(16.) }; let controls = app.data.prefs.topbar_controls.clone(); div() @@ -91,7 +87,15 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> }), ) }) - .child(mode_switch(app, mode, open, stats.files_changed, stats.insertions, stats.deletions, cx)), + .child(mode_switch( + app, + mode, + open, + stats.files_changed, + stats.insertions, + stats.deletions, + cx, + )), ) .child( h_flex() @@ -110,10 +114,9 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> let worktree = worktree.clone(); move |_, _, cx| { view.update(cx, |app, _| { - let _ = app.backend.reveal_path( - app.data.current_profile.as_deref().unwrap_or(""), - None, - ); + let _ = app + .backend + .reveal_path(app.data.current_profile.as_deref().unwrap_or(""), None); let _ = worktree; }); } @@ -146,9 +149,7 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> .child( h_flex() .gap_1() - .children(controls.into_iter().map(|id| { - topbar_control(app, &id, pr.as_ref(), cx) - })) + .children(controls.into_iter().map(|id| topbar_control(app, &id, pr.as_ref(), cx))) .child( Button::new("project-settings") .small() @@ -163,11 +164,7 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> if let Some(pid) = app.data.current_project.clone() { if let Ok(cfg) = app.backend.project_config(&pid) { app.inputs.worktree.update(cx, |s, cx| { - s.set_value( - cfg.worktree_dir.clone().unwrap_or_default(), - window, - cx, - ); + s.set_value(cfg.worktree_dir.clone().unwrap_or_default(), window, cx); }); app.inputs.init_script.update(cx, |s, cx| { s.set_value(cfg.init_script.join("\n"), window, cx); @@ -207,31 +204,44 @@ fn mode_switch( .bg(theme.muted) .p(px(2.)) .gap_1() - .child(mode_btn("files", IconName::Folder, app.t("sidebarFilesTab"), open && mode == SidebarMode::Files, SidebarMode::Files, view.clone())) + .child(mode_btn( + "files", + IconName::Folder, + app.t("sidebarFilesTab"), + open && mode == SidebarMode::Files, + SidebarMode::Files, + view.clone(), + )) .child( h_flex() .id("git-mode-btn") - .child(mode_btn("git", IconName::GitHub, app.t("sidebarGitTab"), open && mode == SidebarMode::Git, SidebarMode::Git, view.clone())) + .child(mode_btn( + "git", + IconName::GitHub, + app.t("sidebarGitTab"), + open && mode == SidebarMode::Git, + SidebarMode::Git, + view.clone(), + )) .when(files > 0, |el| { el.child( h_flex() .gap_1() .pr_1() .text_xs() - .child( - div() - .text_color(rgb(0x22c55e)) - .child(format!("+{ins}")), - ) - .child( - div() - .text_color(rgb(0xef4444)) - .child(format!("-{del}")), - ), + .child(div().text_color(rgb(0x22c55e)).child(format!("+{ins}"))) + .child(div().text_color(rgb(0xef4444)).child(format!("-{del}"))), ) }), ) - .child(mode_btn("notes", IconName::BookOpen, app.t("notes"), open && mode == SidebarMode::Notes, SidebarMode::Notes, view)) + .child(mode_btn( + "notes", + IconName::BookOpen, + app.t("notes"), + open && mode == SidebarMode::Notes, + SidebarMode::Notes, + view, + )) } fn mode_btn( @@ -359,11 +369,7 @@ fn topbar_control( } } -fn profile_sidebar( - app: &mut AppView, - window: &mut Window, - cx: &mut Context, -) -> impl IntoElement { +fn profile_sidebar(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(ws) = app.data.current_ws() else { return div().id("no-profile-sidebar").into_any_element(); }; @@ -396,7 +402,12 @@ fn profile_sidebar( .child(visible(mode == SidebarMode::Notes, notes::render(app, window, cx))), ), ) - .child(sidebar::resize_handle("profile-sidebar-resize", view, true)) + .child(sidebar::resize_handle( + "profile-sidebar-resize", + view, + true, + app.data.overlay.sidebar_resize_focus == Some(true), + )) .into_any_element() } @@ -408,11 +419,7 @@ fn visible(show: bool, child: impl IntoElement) -> impl IntoElement { .child(child) } -fn main_column( - app: &mut AppView, - window: &mut Window, - cx: &mut Context, -) -> impl IntoElement { +fn main_column(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(ws) = app.data.current_ws() else { return div().id("no-main").flex_1().into_any_element(); }; @@ -579,11 +586,7 @@ fn new_terminal_control(app: &AppView, view: gpui::Entity) -> impl Into let view = view.clone(); move |_, _, cx| { view.update(cx, |app, cx| { - app.data.overlay.context_menu = Some(( - crate::state::ContextMenu::NewTerminal, - 200.0, - 80.0, - )); + app.data.overlay.context_menu = Some((crate::state::ContextMenu::NewTerminal, 200.0, 80.0)); cx.notify(); }); } @@ -617,6 +620,14 @@ fn agent_kind_mark(kind: AgentKind) -> impl IntoElement { AgentKind::OpenCode => ("O", gpui::rgb(0x94a3b8)), AgentKind::Grok => ("K", gpui::rgb(0xe5e7eb)), AgentKind::Kimi => ("M", gpui::rgb(0xf472b6)), + AgentKind::Devin => ("D", gpui::rgb(0x14b8a6)), + AgentKind::Droid => ("F", gpui::rgb(0xa855f7)), + AgentKind::Hermes => ("H", gpui::rgb(0xf97316)), + AgentKind::Kilo => ("I", gpui::rgb(0x84cc16)), + AgentKind::Kiro => ("W", gpui::rgb(0x06b6d4)), + AgentKind::Pi => ("π", gpui::rgb(0x64748b)), + AgentKind::Qoder => ("Q", gpui::rgb(0x6366f1)), + AgentKind::Agy => ("Y", gpui::rgb(0xec4899)), AgentKind::Other => ("•", gpui::rgb(0x9ca3af)), AgentKind::Unknown => return div().into_any_element(), }; @@ -633,11 +644,7 @@ fn agent_kind_mark(kind: AgentKind) -> impl IntoElement { .into_any_element() } -fn tab_bodies( - app: &mut AppView, - window: &mut Window, - cx: &mut Context, -) -> impl IntoElement { +fn tab_bodies(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(ws) = app.data.current_ws() else { return div().into_any_element(); }; diff --git a/src-tauri/crates/model/src/pty.rs b/src-tauri/crates/model/src/pty.rs index b1e9688f..6c9cc441 100644 --- a/src-tauri/crates/model/src/pty.rs +++ b/src-tauri/crates/model/src/pty.rs @@ -2,7 +2,7 @@ use crate::schema::pty_sessions; use diesel::prelude::*; use serde::{Deserialize, Serialize}; -#[derive(Queryable, Selectable, Serialize)] +#[derive(Queryable, Selectable, Serialize, Clone)] #[diesel(table_name = pty_sessions)] #[diesel(check_for_backend(diesel::sqlite::Sqlite))] pub struct PtySessionRecord { From d1199801f170c43c7329b7670a9ad2d1e720beb4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 05:50:40 +0000 Subject: [PATCH 06/50] Add GPUI pointer drag-and-drop for sidebar and file tree. Sort-mode project rows, pin/unpin zones, and group headers accept native GPUI drops. File-tree paths can be dragged onto folders or the tree root to move them. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 15 ++++ src-gpui/src/backend.rs | 4 + src-gpui/src/ui/file_tree.rs | 38 ++++++++++ src-gpui/src/ui/mod.rs | 12 ++- src-gpui/src/ui/sidebar.rs | 140 +++++++++++++++++++++++++++-------- 5 files changed, 178 insertions(+), 31 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 0345abcd..8f3dedcb 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -970,6 +970,21 @@ impl AppView { self.reload_projects(); } + pub fn drop_tree_paths(&mut self, sources: &[String], target_dir: Option<&str>) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + if sources.is_empty() { + return; + } + if let Err(err) = self.backend.move_paths(&profile_id, sources, target_dir) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + self.load_tree_root(&profile_id); + } + pub fn drop_sidebar_project(&mut self, dragged: &str, target: Option<&str>, unpin: bool) { if let Err(err) = self.backend.drop_project(dragged, target, unpin) { self.data diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index 33630212..02c9b045 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -303,6 +303,10 @@ impl Backend { service::filesystem::delete_file_tree_paths(&self.db, profile_id, paths) } + pub fn move_paths(&self, profile_id: &str, sources: &[String], target_dir: Option<&str>) -> Result<(), AppError> { + service::filesystem::move_file_tree_paths(&self.db, profile_id, sources, target_dir) + } + pub fn reveal_path(&self, profile_id: &str, path: Option<&str>) -> Result<(), AppError> { service::filesystem::reveal_path_in_file_manager(&self.db, profile_id, path) } diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index e3b8342a..250d2c25 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -6,6 +6,11 @@ use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, Style use crate::app::AppView; use crate::state::ContextMenu; +#[derive(Clone)] +struct TreeDrag { + path: String, +} + pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); let view = cx.entity(); @@ -35,6 +40,15 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }); } }) + .on_drop({ + let view = view.clone(); + move |drag: &TreeDrag, _, cx| { + view.update(cx, |app, cx| { + app.drop_tree_paths(&[drag.path.clone()], None); + cx.notify(); + }); + } + }) .when_some(err, |el, err| { el.child(div().text_xs().text_color(theme.danger).opacity(0.8).child(err)) }) @@ -123,6 +137,30 @@ fn node_view( }); } }) + .on_drag( + TreeDrag { + path: path_owned.clone(), + }, + |info, _, _, cx| { + cx.new(|_| crate::ui::DragGhost { + label: info.path.clone(), + }) + }, + ) + .when(node.is_dir, |el| { + el.on_drop({ + let view = view.clone(); + let dest = path_owned.clone(); + move |drag: &TreeDrag, _, cx| { + view.update(cx, |app, cx| { + if drag.path != dest { + app.drop_tree_paths(&[drag.path.clone()], Some(&dest)); + cx.notify(); + } + }); + } + }) + }) .on_mouse_down(MouseButton::Right, { let view = view.clone(); let path = path_owned.clone(); diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs index 2c49a938..205c8acf 100644 --- a/src-gpui/src/ui/mod.rs +++ b/src-gpui/src/ui/mod.rs @@ -1,9 +1,19 @@ -use gpui::SharedString; +use gpui::{div, prelude::*, Context, SharedString, Window}; pub fn eid(s: impl Into) -> SharedString { SharedString::from(s.into()) } +pub struct DragGhost { + pub label: String, +} + +impl gpui::Render for DragGhost { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().px_2().py_1().rounded_md().opacity(0.45).child(self.label.clone()) + } +} + pub mod debug; pub mod dialogs; pub mod file_tree; diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index 442025b2..095d18a4 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -8,6 +8,12 @@ use crate::backend; use crate::state::{AgentStatus, ContextMenu, DialogKind, Route}; use crate::ui::settings; +#[derive(Clone)] +struct SidebarDrag { + id: String, + name: String, +} + pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { if app.data.prefs.sidebar_collapsed { return div().id("app-sidebar-collapsed").into_any_element(); @@ -148,6 +154,28 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .into_any_element() } +fn drop_zone( + id: &'static str, + label: String, + border: gpui::Hsla, + muted: gpui::Hsla, + on_drop: impl Fn(&SidebarDrag, &mut gpui::App) + 'static, +) -> impl IntoElement { + div() + .id(id) + .mt_2() + .px_2() + .py_3() + .rounded_md() + .border_1() + .border_dashed() + .border_color(border) + .text_xs() + .text_color(muted) + .child(label) + .on_drop(move |drag: &SidebarDrag, _, cx| on_drop(drag, cx)) +} + pub(crate) fn resize_handle( id: &'static str, view: gpui::Entity, @@ -214,11 +242,30 @@ fn project_sections(app: &mut AppView, _window: &mut Window, cx: &mut Context Date: Thu, 3 Sep 2026 05:57:33 +0000 Subject: [PATCH 07/50] Port the full agent detector and add in-app update install. Replace the phrase/alias detector with the TS engine (regions, gates, idle confirmation) and all 18 agent manifests. Capture OSC title via vt100 callbacks. Download the GitHub release asset and open it from About. Git diffs gain +/- counts, image preview, and a review-comment queue. Co-authored-by: AkaraChen --- messages/en.json | 3 +- messages/zh.json | 3 +- src-gpui/Cargo.lock | 1 + src-gpui/Cargo.toml | 1 + src-gpui/src/app.rs | 22 + src-gpui/src/detector.rs | 168 ----- src-gpui/src/detector/manifests.rs | 964 +++++++++++++++++++++++++++++ src-gpui/src/detector/mod.rs | 438 +++++++++++++ src-gpui/src/state.rs | 13 +- src-gpui/src/ui/git.rs | 166 ++++- src-gpui/src/ui/settings.rs | 20 +- src-gpui/src/updater.rs | 130 +++- 12 files changed, 1735 insertions(+), 194 deletions(-) delete mode 100644 src-gpui/src/detector.rs create mode 100644 src-gpui/src/detector/manifests.rs create mode 100644 src-gpui/src/detector/mod.rs diff --git a/messages/en.json b/messages/en.json index da50bf83..7f318803 100644 --- a/messages/en.json +++ b/messages/en.json @@ -361,5 +361,6 @@ "notesCodeBlockHidePreview": "Hide preview", "reviewCommentsCopied": "Review comments copied", "reviewCommentsCopiedAndCleared": "Review comments copied and cleared", - "reviewQueue": "Review Queue" + "reviewQueue": "Review Queue", + "gitReviewCommentPlaceholder": "Write a review comment" } diff --git a/messages/zh.json b/messages/zh.json index 6e028d66..39495998 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -361,5 +361,6 @@ "notesCodeBlockHidePreview": "隐藏预览", "reviewCommentsCopied": "评论已复制", "reviewCommentsCopiedAndCleared": "评论已复制并清空", - "reviewQueue": "审查队列" + "reviewQueue": "审查队列", + "gitReviewCommentPlaceholder": "写一条审查评论" } diff --git a/src-gpui/Cargo.lock b/src-gpui/Cargo.lock index 36eebf53..04f1003a 100644 --- a/src-gpui/Cargo.lock +++ b/src-gpui/Cargo.lock @@ -957,6 +957,7 @@ dependencies = [ "libsqlite3-sys", "model", "open", + "regex", "repo", "rfd", "serde", diff --git a/src-gpui/Cargo.toml b/src-gpui/Cargo.toml index 69790077..4b4f0db9 100644 --- a/src-gpui/Cargo.toml +++ b/src-gpui/Cargo.toml @@ -30,6 +30,7 @@ uuid = { version = "1", features = ["v4"] } open = "5" ureq = { version = "2", features = ["json"] } fontdb = "0.23" +regex = "1" [target.'cfg(target_os = "windows")'.dependencies] libsqlite3-sys = { version = "0.37", features = ["bundled"] } diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 8f3dedcb..bec56b5c 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -67,6 +67,7 @@ pub struct Inputs { pub custom_shell: Entity, pub default_worktree: Entity, pub term_search: Entity, + pub review_comment: Entity, } pub struct AppView { @@ -135,6 +136,7 @@ impl AppView { custom_shell: input(window, cx, "", false), default_worktree: input(window, cx, "", false), term_search: input(window, cx, "", false), + review_comment: input(window, cx, "", true), }; inputs.project_name.update(cx, |s, cx| { @@ -167,6 +169,9 @@ impl AppView { inputs.term_search.update(cx, |s, cx| { s.set_placeholder(i18n::t(locale, "terminalSearchPlaceholder"), window, cx); }); + inputs.review_comment.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "gitReviewCommentPlaceholder"), window, cx); + }); inputs.default_worktree.update(cx, |s, cx| { s.set_placeholder(i18n::t(locale, "defaultWorktreeDirPlaceholder"), window, cx); s.set_value(prefs.worktree_dir.clone(), window, cx); @@ -1236,6 +1241,23 @@ impl AppView { } } + pub fn add_review_comment(&mut self, window: &mut Window, cx: &mut Context) { + let body = self.inputs.review_comment.read(cx).value().to_string(); + let Some((file, line)) = self.data.overlay.review_line.clone() else { + return; + }; + if body.trim().is_empty() { + return; + } + self.data + .overlay + .review_comments + .push(format!("{file}: {line}\n{body}")); + self.inputs.review_comment.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + } + pub fn select_commit(&mut self, hash: &str) { self.data.overlay.git_selected_commit = Some(hash.to_string()); if let Some(profile_id) = self.data.current_profile.clone() { diff --git a/src-gpui/src/detector.rs b/src-gpui/src/detector.rs deleted file mode 100644 index 41d1eeaf..00000000 --- a/src-gpui/src/detector.rs +++ /dev/null @@ -1,168 +0,0 @@ -use crate::state::{AgentKind, AgentStatus}; - -pub fn detect(title: &str, screen: &str, previous: AgentStatus) -> (AgentKind, AgentStatus) { - let hay = format!("{title}\n{screen}").to_ascii_lowercase(); - let kind = detect_kind(&hay); - let status = detect_status(&hay, previous); - (kind, status) -} - -fn detect_kind(hay: &str) -> AgentKind { - const RULES: &[(&str, AgentKind)] = &[ - ("claude-code", AgentKind::Claude), - ("claude", AgentKind::Claude), - ("codex", AgentKind::Codex), - ("gemini", AgentKind::Gemini), - ("cursor", AgentKind::Cursor), - ("github copilot", AgentKind::Copilot), - ("copilot", AgentKind::Copilot), - ("amp", AgentKind::Amp), - ("cline", AgentKind::Cline), - ("opencode", AgentKind::OpenCode), - ("grok", AgentKind::Grok), - ("kimi", AgentKind::Kimi), - ("devin", AgentKind::Devin), - ("droid", AgentKind::Droid), - ("factory droid", AgentKind::Droid), - ("hermes", AgentKind::Hermes), - ("kilo", AgentKind::Kilo), - ("kiro", AgentKind::Kiro), - ("qoder", AgentKind::Qoder), - ("qodercli", AgentKind::Qoder), - ("agy", AgentKind::Agy), - ("pi agent", AgentKind::Pi), - ("aider", AgentKind::Other), - ]; - for (needle, kind) in RULES { - if hay.contains(needle) { - return *kind; - } - } - AgentKind::Unknown -} - -fn detect_status(hay: &str, previous: AgentStatus) -> AgentStatus { - if is_blocked(hay) { - return AgentStatus::Waiting; - } - if is_working(hay) { - return AgentStatus::Running; - } - if is_idle_prompt(hay) { - return if matches!(previous, AgentStatus::Running | AgentStatus::Waiting) { - AgentStatus::Completed - } else { - AgentStatus::Idle - }; - } - previous -} - -fn is_blocked(hay: &str) -> bool { - const PHRASES: &[&str] = &[ - "waiting for permission", - "waiting for", - "waiting on", - "allow command?", - "action required", - "press enter to confirm or esc to cancel", - "enter to submit answer", - "enter to select", - "esc to cancel", - "do you want to continue", - "do you want to proceed", - "run a dynamic workflow?", - "waiting for permission", - "allow this command", - "bash command", - "[y/n]", - "yes (y)", - "would you like to", - "enter to set as default", - ]; - PHRASES.iter().any(|p| hay.contains(p)) -} - -fn is_working(hay: &str) -> bool { - const PHRASES: &[&str] = &[ - "thinking", - "running", - "working", - "compiling", - "generating", - "esc to cancel", - ]; - if hay.contains("esc to cancel") && hay.contains("enter to select") { - return false; - } - PHRASES.iter().any(|p| hay.contains(p)) || has_braille_spinner(hay) -} - -fn is_idle_prompt(hay: &str) -> bool { - hay.contains("❯") - || hay.contains("idle") - || hay.lines().rev().take(6).any(|line| { - let t = line.trim(); - t == ">" || t.ends_with(" $") || t.ends_with('$') || t.starts_with("❯") - }) -} - -fn has_braille_spinner(hay: &str) -> bool { - hay.chars().any(|c| ('\u{2800}'..='\u{28FF}').contains(&c)) -} - -pub fn clickable_tokens(screen: &str) -> Vec { - let mut out = Vec::new(); - for token in screen.split_whitespace() { - let clean = token.trim_matches(|c: char| matches!(c, ',' | ';' | ')' | '(' | '[' | ']' | '"' | '\'' | '`')); - if clean.starts_with("http://") || clean.starts_with("https://") { - out.push(Clickable::Url(clean.to_string())); - } else if looks_like_path(clean) { - out.push(Clickable::Path(clean.to_string())); - } - } - out.dedup(); - out.into_iter().take(8).collect() -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Clickable { - Url(String), - Path(String), -} - -fn looks_like_path(token: &str) -> bool { - if token.len() < 3 || token.contains("://") { - return false; - } - (token.contains('/') || token.contains('.')) - && token - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | '~')) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn detects_claude_waiting() { - let (kind, status) = detect("claude", "waiting for permission", AgentStatus::Running); - assert_eq!(kind, AgentKind::Claude); - assert_eq!(status, AgentStatus::Waiting); - } - - #[test] - fn detects_codex_working() { - let (kind, status) = detect("codex", "thinking about the change", AgentStatus::Idle); - assert_eq!(kind, AgentKind::Codex); - assert_eq!(status, AgentStatus::Running); - } - - #[test] - fn extracts_urls_and_paths() { - let tokens = clickable_tokens("see https://example.com and src/app.rs please"); - assert!(tokens.contains(&Clickable::Url("https://example.com".into()))); - assert!(tokens.contains(&Clickable::Path("src/app.rs".into()))); - } -} diff --git a/src-gpui/src/detector/manifests.rs b/src-gpui/src/detector/manifests.rs new file mode 100644 index 00000000..067ec772 --- /dev/null +++ b/src-gpui/src/detector/manifests.rs @@ -0,0 +1,964 @@ +//! Rule tables ported from `src/features/terminal/detector/rules/*.ts`. + +use super::{Gate, Manifest, Region, Rule, Semantic}; +use crate::state::AgentKind; + +const fn c(contains: &'static [&'static str]) -> Gate { + Gate { + contains, + ..Gate::EMPTY + } +} + +const fn r(regex: &'static [&'static str]) -> Gate { + Gate { regex, ..Gate::EMPTY } +} + +const fn l(line_regex: &'static [&'static str]) -> Gate { + Gate { + line_regex, + ..Gate::EMPTY + } +} + +macro_rules! rule { + ($id:literal, $state:ident, $pri:expr, $region:expr, $gate:expr) => { + Rule { + id: $id, + state: Semantic::$state, + priority: $pri, + region: $region, + visible_idle: false, + skip_state: false, + gate: $gate, + } + }; + ($id:literal, $state:ident, $pri:expr, $region:expr, $gate:expr, visible_idle) => { + Rule { + id: $id, + state: Semantic::$state, + priority: $pri, + region: $region, + visible_idle: true, + skip_state: false, + gate: $gate, + } + }; + ($id:literal, $state:ident, $pri:expr, $region:expr, $gate:expr, skip_state) => { + Rule { + id: $id, + state: Semantic::$state, + priority: $pri, + region: $region, + visible_idle: false, + skip_state: true, + gate: $gate, + } + }; +} + +pub const MANIFESTS: &[Manifest] = &[ + CLAUDE, CODEX, OPENCODE, AMP, AGY, CLINE, CURSOR, DEVIN, DROID, GEMINI, COPILOT, GROK, HERMES, KILO, KIMI, KIRO, + PI, QODER, +]; + +const CLAUDE: Manifest = Manifest { + id: AgentKind::Claude, + aliases: &["claude-code"], + rules: &[ + rule!( + "osc_title_working", + Working, + 1100, + Region::OscTitle, + r(&[r"^[\u{2800}-\u{28FF}] "]) + ), + rule!( + "live_blocked_form", + Blocked, + 980, + Region::AfterLastHorizontalRule, + Gate { + contains: &["enter to select", "esc to cancel"], + any: &[ + c(&["tab/arrow keys to navigate"]), + c(&["arrow keys to navigate"]), + c(&["arrows to navigate"]), + c(&["↑/↓ to navigate"]), + c(&["↑↓ to navigate"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "dynamic_workflow_prompt", + Blocked, + 980, + Region::WholeRecent, + c(&["run a dynamic workflow?", "esc to cancel"]) + ), + rule!( + "live_prompt_box", + Idle, + 950, + Region::PromptBoxBody, + Gate { + line_regex: &[r"^\s*❯"], + not: &[ + c(&["enter to select"]), + c(&["esc to cancel"]), + c(&["tab/arrow keys"]), + c(&["arrow keys to navigate"]), + c(&["↑/↓ to navigate"]), + ], + ..Gate::EMPTY + }, + visible_idle + ), + rule!( + "bash_permission_prompt", + Blocked, + 850, + Region::WholeRecent, + Gate { + contains: &["do you want to proceed?"], + any: &[ + c(&["bash command"]), + c(&["bash("]), + c(&["contains expansion"]), + c(&["tab to amend"]), + c(&["ctrl+e to explain"]), + ], + all: &[Gate { + any: &[ + l(&[r"^\s*(?:❯\s*)?yes\b"]), + l(&[r"^\s*1\.\s*yes\b"]), + l(&[r"^\s*2\.\s*no\b"]), + ], + ..Gate::EMPTY + }], + ..Gate::EMPTY + } + ), + rule!( + "generic_permission_prompt", + Blocked, + 840, + Region::AfterLastHorizontalRule, + Gate { + contains: &["do you want to proceed?", "esc to cancel"], + all: &[Gate { + any: &[ + l(&[r"^\s*(?:❯\s*)?1\.\s*yes\b"]), + l(&[r"^\s*2\.\s*yes\b"]), + l(&[r"^\s*2\.\s*no\b"]), + l(&[r"^\s*3\.\s*no\b"]), + ], + ..Gate::EMPTY + }], + ..Gate::EMPTY + } + ), + rule!( + "legacy_no_prompt_blocker", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + Gate { + contains: &["do you want to"], + any: &[c(&["yes"]), c(&["❯"])], + ..Gate::EMPTY + }, + Gate { + contains: &["would you like to"], + any: &[c(&["yes"]), c(&["❯"])], + ..Gate::EMPTY + }, + c(&["waiting for permission"]), + c(&["do you want to allow this connection?"]), + c(&["tab to amend"]), + c(&["ctrl+e to explain"]), + c(&["do you want to proceed?", "esc to cancel"]), + c(&["review your answers"]), + c(&["skip interview and plan immediately"]), + ], + not: &[r(&[r"^\s*❯\s*$"])], + ..Gate::EMPTY + } + ), + rule!( + "osc_title_idle", + Idle, + 250, + Region::OscTitle, + r(&[r"^\u{2733} "]), + visible_idle + ), + rule!("osc_progress_idle", Idle, 250, Region::OscProgress, r(&[r"^4;0"])), + ], +}; + +const CODEX: Manifest = Manifest { + id: AgentKind::Codex, + aliases: &[], + rules: &[ + rule!( + "osc_title_blocked", + Blocked, + 1100, + Region::OscTitle, + c(&["Action Required"]) + ), + rule!( + "osc_title_working", + Working, + 1050, + Region::OscTitle, + r(&[r"^[\u{2800}-\u{28FF}] "]) + ), + rule!( + "live_strong_blocker", + Blocked, + 900, + Region::AfterLastPromptMarker, + Gate { + any: &[ + c(&["press enter to confirm or esc to cancel"]), + c(&["enter to submit answer"]), + c(&["enter to submit all"]), + c(&["allow command?"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "weak_blocker", + Blocked, + 600, + Region::WholeRecent, + Gate { + any: &[ + c(&["[y/n]"]), + c(&["yes (y)"]), + Gate { + contains: &["do you want to"], + any: &[c(&["yes"]), c(&["❯"])], + ..Gate::EMPTY + }, + Gate { + contains: &["would you like to"], + any: &[c(&["yes"]), c(&["❯"])], + ..Gate::EMPTY + }, + ], + ..Gate::EMPTY + } + ), + rule!( + "osc_title_idle", + Idle, + 100, + Region::OscTitle, + Gate { + regex: &[r"\S"], + not: &[r(&[r"^[\u{2800}-\u{28FF}]"]), c(&["Action Required"])], + ..Gate::EMPTY + }, + visible_idle + ), + ], +}; + +const OPENCODE: Manifest = Manifest { + id: AgentKind::OpenCode, + aliases: &["open-code"], + rules: &[ + rule!( + "permission_required", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + c(&["△ Permission required"]), + Gate { + contains: &["esc dismiss"], + any: &[c(&["enter confirm"]), c(&["enter submit"]), c(&["enter toggle"])], + all: &[Gate { + any: &[c(&["↑↓ select"]), c(&["⇆ tab"])], + ..Gate::EMPTY + }], + ..Gate::EMPTY + }, + ], + ..Gate::EMPTY + } + ), + rule!( + "interrupt_hint_working", + Working, + 110, + Region::WholeRecent, + Gate { + any: &[ + c(&["esc to interrupt"]), + c(&["ctrl+c to interrupt"]), + c(&["press esc to interrupt"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "progress_bar_working", + Working, + 100, + Region::WholeRecent, + r(&["(■|⬝){4,}"]) + ), + ], +}; + +const AMP: Manifest = Manifest { + id: AgentKind::Amp, + aliases: &["amp-local"], + rules: &[ + rule!( + "approval_footer", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + c(&["waiting for approval"]), + c(&["invoke tool"]), + c(&["run this command?"]), + c(&["allow editing file:"]), + c(&["allow creating file:"]), + c(&["confirm tool call"]), + Gate { + contains: &["approve"], + any: &[ + c(&["allow all for this session"]), + c(&["allow all for every session"]), + c(&["allow file for every session"]), + c(&["deny with feedback"]), + ], + ..Gate::EMPTY + }, + ], + ..Gate::EMPTY + } + ), + rule!( + "esc_cancel_working", + Working, + 100, + Region::WholeRecent, + c(&["esc to cancel"]) + ), + ], +}; + +const AGY: Manifest = Manifest { + id: AgentKind::Agy, + aliases: &["antigravity", "antigravity-cli"], + rules: &[ + rule!( + "permission_prompt", + Blocked, + 300, + Region::WholeRecent, + Gate { + contains: &["requesting permission for:"], + any: &[c(&["do you want to proceed?"]), c(&["tab amend", "edit command"])], + ..Gate::EMPTY + } + ), + rule!( + "spinner_working", + Working, + 100, + Region::WholeRecent, + l(&[r"^\s*[\u{2800}-\u{28FF}]+\s+\p{Alphabetic}+(?:[\d_]\w*)?ing\b"]) + ), + rule!( + "background_tasks_working", + Working, + 90, + Region::BottomNonEmpty(5), + l(&[r"·\s*[1-9]\d*\s+task"]) + ), + ], +}; + +const CLINE: Manifest = Manifest { + id: AgentKind::Cline, + aliases: &[], + rules: &[ + rule!( + "tool_permission", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + c(&["let cline use this tool"]), + c(&["[act mode]", "execute command?", "yes"]), + c(&["[act mode]", "use this tool?", "yes"]), + c(&["[plan mode]", "execute command?", "yes"]), + c(&["[plan mode]", "use this tool?", "yes"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "default_cline_working", + Working, + -10, + Region::WholeRecent, + r(&[r"[\s\S]+"]) + ), + ], +}; + +const CURSOR: Manifest = Manifest { + id: AgentKind::Cursor, + aliases: &["cursor-agent"], + rules: &[ + rule!( + "write_file_approval", + Blocked, + 320, + Region::BottomNonEmpty(8), + Gate { + contains: &["write to this file?", "proceed (y)"], + any: &[ + c(&["reject & propose changes"]), + c(&["esc or n or p"]), + c(&["add write("]), + ], + ..Gate::EMPTY + } + ), + rule!( + "approval_prompt", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + Gate { + contains: &["waiting for approval", "run this command?"], + any: &[c(&["run (once) (y)"]), c(&["skip (esc or n)"])], + ..Gate::EMPTY + }, + c(&["(y) (enter)"]), + l(&[r"^\s*allow .*\(y\)"]), + c(&["keep (n)"]), + c(&["skip (esc or n)"]), + c(&["(y)", "allow"]), + c(&["(y)", "run (once)"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "stop_hint_working", + Working, + 100, + Region::BottomNonEmpty(6), + c(&["ctrl+c to stop"]) + ), + rule!( + "background_task_status_working", + Working, + 95, + Region::BottomNonEmpty(5), + l(&[r"\b[1-9]\d*\s+background\s+tasks?\b"]) + ), + rule!( + "spinner_working", + Working, + 90, + Region::BottomNonEmpty(8), + l(&[r"^\s*(⬡|⬢|[\u{2800}-\u{28FF}]+)\s+\p{Alphabetic}"]) + ), + ], +}; + +const DEVIN: Manifest = Manifest { + id: AgentKind::Devin, + aliases: &["devin-cli", "devin cli"], + rules: &[ + rule!( + "workspace_trust_prompt", + Blocked, + 300, + Region::BottomNonEmpty(8), + c(&[ + "do you trust the authors of this directory?", + "with untrusted content.", + "yes, trust ", + ]) + ), + rule!( + "permission_prompt", + Blocked, + 290, + Region::BottomNonEmpty(8), + c(&["approve once", "select", "confirm", "esc cancel"]) + ), + rule!( + "running_tools_footer", + Working, + 200, + Region::BottomNonEmpty(8), + Gate { + contains: &["running tools", "esc to interrupt"], + not: &[c(&["approve once", "esc cancel"])], + ..Gate::EMPTY + } + ), + rule!( + "guide_while_working", + Working, + 190, + Region::BottomNonEmpty(6), + Gate { + contains: &["guide devin while it works"], + not: &[c(&["approve once", "esc cancel"])], + ..Gate::EMPTY + } + ), + rule!( + "welcome_prompt_footer", + Idle, + 120, + Region::BottomNonEmpty(8), + Gate { + contains: &["ask devin to build", "features, fix bugs", "your code"], + line_regex: &[r"^\s*❭ Ask Devin to build"], + not: &[ + c(&["approve once", "esc cancel"]), + c(&["running tools", "esc to interrupt"]), + ], + ..Gate::EMPTY + }, + visible_idle + ), + rule!( + "live_prompt_footer", + Idle, + 100, + Region::BottomNonEmpty(6), + Gate { + contains: &["context:"], + line_regex: &[r"^\s*❭"], + not: &[ + c(&["approve once", "esc cancel"]), + c(&["running tools", "esc to interrupt"]), + ], + ..Gate::EMPTY + }, + visible_idle + ), + ], +}; + +const DROID: Manifest = Manifest { + id: AgentKind::Droid, + aliases: &["factory droid"], + rules: &[ + rule!( + "execute_selection_blocker", + Blocked, + 300, + Region::WholeRecent, + Gate { + contains: &["enter to select", "esc to cancel"], + any: &[c(&["↑↓ to navigate"]), c(&["use ↑↓ to navigate"])], + all: &[Gate { + any: &[c(&["> yes, allow"]), c(&["> no, cancel"])], + ..Gate::EMPTY + }], + ..Gate::EMPTY + } + ), + rule!( + "selection_menu_blocker", + Blocked, + 290, + Region::BottomNonEmpty(8), + Gate { + contains: &["enter select", "esc cancel"], + any: &[c(&["↑/↓ navigate"]), c(&["↑↓ navigate"])], + ..Gate::EMPTY + } + ), + rule!( + "spinner_stop_working", + Working, + 110, + Region::WholeRecent, + Gate { + contains: &["esc to stop"], + line_regex: &[r"^\s*[\u{2800}-\u{28FF}]"], + ..Gate::EMPTY + } + ), + rule!( + "stop_hint_working", + Working, + 100, + Region::WholeRecent, + c(&["esc to stop"]) + ), + ], +}; + +const GEMINI: Manifest = Manifest { + id: AgentKind::Gemini, + aliases: &[], + rules: &[ + rule!( + "apply_or_allow_change", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + c(&["│ Apply this change"]), + c(&["│ Allow execution"]), + Gate { + all: &[c(&["yes"])], + any: &[ + c(&["waiting for user confirmation"]), + c(&["│ Do you want to proceed"]), + c(&["do you want to proceed?"]), + ], + ..Gate::EMPTY + }, + l(&[r"^\s*❯.*(yes|allow)"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "esc_cancel_working", + Working, + 100, + Region::WholeRecent, + c(&["esc to cancel"]) + ), + ], +}; + +const COPILOT: Manifest = Manifest { + id: AgentKind::Copilot, + aliases: &["github-copilot", "github copilot", "ghcs"], + rules: &[ + rule!( + "selection_blocker", + Blocked, + 300, + Region::WholeRecent, + Gate { + all: &[ + Gate { + any: &[c(&["esc to cancel"]), c(&["esc cancel"])], + ..Gate::EMPTY + }, + Gate { + any: &[ + c(&["enter to select"]), + c(&["enter to confirm"]), + c(&["enter to submit"]), + c(&["enter accept"]), + ], + ..Gate::EMPTY + }, + ], + ..Gate::EMPTY + } + ), + rule!( + "working_cancel_hint", + Working, + 100, + Region::WholeRecent, + Gate { + any: &[c(&["esc to cancel"]), c(&["esc cancel"]), c(&["esc again to cancel"]),], + ..Gate::EMPTY + } + ), + ], +}; + +const GROK: Manifest = Manifest { + id: AgentKind::Grok, + aliases: &["grok-build"], + rules: &[ + rule!( + "permission_scope_selector", + Blocked, + 300, + Region::WholeRecent, + Gate { + contains: &["yes, proceed", "no, reject"], + any: &[c(&["use ← → to choose permission whitelist scope"]), c(&["←/→:scope"]),], + ..Gate::EMPTY + } + ), + rule!( + "waiting_tool_working", + Working, + 120, + Region::WholeRecent, + Gate { + any: &[ + Gate { + all: &[c(&["ctrl+c:cancel", "ctrl+enter:interject"]), c(&["waiting"]),], + ..Gate::EMPTY + }, + l(&[r"^\s*[\u{2800}-\u{28FF}]\s+(Run|Read|Search|List)\b"]), + ], + ..Gate::EMPTY + } + ), + ], +}; + +const HERMES: Manifest = Manifest { + id: AgentKind::Hermes, + aliases: &["hermes-agent"], + rules: &[ + rule!( + "dangerous_command_approval", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + c(&["dangerous command"]), + c(&["allow once", "allow for this session", "deny"]), + ], + all: &[Gate { + any: &[ + c(&["enter to confirm"]), + c(&["↑/↓ to select"]), + c(&["show full command"]), + ], + ..Gate::EMPTY + }], + ..Gate::EMPTY + } + ), + rule!( + "interrupt_status_working", + Working, + 100, + Region::WholeRecent, + Gate { + any: &[c(&["msg=interrupt"]), c(&["ctrl+c cancel"])], + ..Gate::EMPTY + } + ), + ], +}; + +const KILO: Manifest = Manifest { + id: AgentKind::Kilo, + aliases: &["kilo-code", "kilo code"], + rules: &[ + rule!( + "opencode_permission", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[c(&["△ Permission required"]), c(&["esc dismiss", "enter confirm"])], + ..Gate::EMPTY + } + ), + rule!( + "esc_interrupt_working", + Working, + 100, + Region::WholeRecent, + c(&["esc interrupt"]) + ), + ], +}; + +const KIMI: Manifest = Manifest { + id: AgentKind::Kimi, + aliases: &["kimi-code", "kimi code"], + rules: &[ + rule!( + "current_approval_panel", + Blocked, + 400, + Region::WholeRecent, + Gate { + contains: &["↵ confirm"], + any: &[ + c(&["run this command?"]), + c(&["write this file?"]), + c(&["apply these edits?"]), + c(&["stop this task?"]), + c(&["ready to build with this plan?"]), + ], + all: &[ + c(&[" choose"]), + Gate { + any: &[c(&["approve"]), c(&["reject"]), c(&["revise"])], + ..Gate::EMPTY + }, + ], + ..Gate::EMPTY + } + ), + rule!( + "legacy_approval_panel", + Blocked, + 300, + Region::WholeRecent, + Gate { + contains: &["requesting approval", "reject"], + any: &[c(&["approve once"]), c(&["approve for this session"])], + ..Gate::EMPTY + } + ), + rule!( + "moon_spinner_working", + Working, + 100, + Region::WholeRecent, + l(&[r"^\s*([🌕🌖🌗🌘🌑🌒🌓🌔])\s*$"]) + ), + rule!( + "braille_spinner_working", + Working, + 90, + Region::WholeRecent, + l(&[r"^\s*[\u{2800}-\u{28FF}]+\s*(thinking\.\.\.|working\.\.\.|using )"]) + ), + ], +}; + +const KIRO: Manifest = Manifest { + id: AgentKind::Kiro, + aliases: &["kiro-cli"], + rules: &[ + rule!( + "tool_approval", + Blocked, + 300, + Region::WholeRecent, + Gate { + contains: &["requires approval"], + any: &[ + c(&["yes, single permission"]), + c(&["trust, always allow"]), + c(&["no (tab to edit)"]), + c(&["esc to close"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "subagent_approval", + Blocked, + 290, + Region::WholeRecent, + Gate { + contains: &["pending from subagents"], + any: &[c(&["tool approval"]), c(&["tool approvals"])], + all: &[Gate { + any: &[ + c(&["approve all pending"]), + c(&["configure individually"]), + c(&["exit (cancel subagents)"]), + ], + ..Gate::EMPTY + }], + ..Gate::EMPTY + } + ), + rule!( + "kiro_working_marker", + Working, + 100, + Region::WholeRecent, + c(&["kiro is working"]) + ), + rule!( + "tool_spinner_working", + Working, + 90, + Region::WholeRecent, + Gate { + contains: &["esc to cancel"], + line_regex: &[r"^\s*([◔◑◕●])\s+\p{Alphabetic}"], + ..Gate::EMPTY + } + ), + ], +}; + +const PI: Manifest = Manifest { + id: AgentKind::Pi, + aliases: &["pi agent"], + rules: &[rule!( + "working_literal", + Working, + 100, + Region::WholeRecent, + c(&["Working..."]) + )], +}; + +const QODER: Manifest = Manifest { + id: AgentKind::Qoder, + aliases: &["qoderclicn", "qoder", "qodercn"], + rules: &[ + rule!( + "confirmation_or_input_blocker", + Blocked, + 300, + Region::WholeRecent, + Gate { + any: &[ + Gate { + contains: &["waiting for user confirmation"], + any: &[c(&["yes"]), c(&["no"]), c(&["allow"]), c(&["reject"])], + ..Gate::EMPTY + }, + c(&["permission required"]), + c(&["allow once or always?"]), + c(&["asking user"]), + c(&["enter your response"]), + c(&["review your answers:"]), + c(&["shell awaiting input"]), + ], + ..Gate::EMPTY + } + ), + rule!( + "cancel_hint_working", + Working, + 100, + Region::WholeRecent, + c(&["(esc to cancel,"]) + ), + rule!( + "spinner_working", + Working, + 90, + Region::WholeRecent, + l(&[r"^\s*[\u{2800}-\u{28FF}]\s+(?:\S.*)?\p{Alphabetic}"]) + ), + ], +}; diff --git a/src-gpui/src/detector/mod.rs b/src-gpui/src/detector/mod.rs new file mode 100644 index 00000000..e39d59cf --- /dev/null +++ b/src-gpui/src/detector/mod.rs @@ -0,0 +1,438 @@ +//! Agent-status detector ported from `src/features/terminal/detector`. +//! Regions, gates, idle confirmation, and per-agent manifests match the TS engine. + +mod manifests; + +use std::cell::RefCell; +use std::collections::HashMap; +use std::time::Instant; + +use regex::Regex; + +use crate::state::{AgentKind, AgentStatus}; + +use manifests::MANIFESTS; + +const IDLE_CONFIRMATIONS: u32 = 3; +const IDLE_CONFIRM_CAP_MS: u128 = 700; +const PROMPT_MARKERS: &[&str] = &["❯", "❭", "›"]; +const SPINNER_TITLE: &str = r"^[\u{2800}-\u{28FF}] "; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Semantic { + Idle, + Working, + Blocked, + #[default] + Unknown, +} + +#[derive(Clone, Copy, Debug)] +pub enum Region { + OscTitle, + OscProgress, + AfterLastHorizontalRule, + AfterLastPromptMarker, + PromptBoxBody, + WholeRecent, + BottomLines(usize), + BottomNonEmpty(usize), +} + +#[derive(Clone, Copy, Debug)] +pub struct Gate { + pub contains: &'static [&'static str], + pub regex: &'static [&'static str], + pub line_regex: &'static [&'static str], + pub all: &'static [Gate], + pub any: &'static [Gate], + pub not: &'static [Gate], +} + +impl Gate { + pub const EMPTY: Self = Self { + contains: &[], + regex: &[], + line_regex: &[], + all: &[], + any: &[], + not: &[], + }; +} + +#[derive(Clone, Copy, Debug)] +pub struct Rule { + pub id: &'static str, + pub state: Semantic, + pub priority: i32, + pub region: Region, + pub visible_idle: bool, + pub skip_state: bool, + pub gate: Gate, +} + +#[derive(Clone, Copy, Debug)] +pub struct Manifest { + pub id: AgentKind, + pub aliases: &'static [&'static str], + pub rules: &'static [Rule], +} + +#[derive(Default)] +pub struct OscSink { + pub title: String, + pub progress: String, +} + +impl vt100::Callbacks for OscSink { + fn set_window_title(&mut self, _: &mut vt100::Screen, title: &[u8]) { + self.title = String::from_utf8_lossy(title).into_owned(); + } + + fn unhandled_osc(&mut self, _: &mut vt100::Screen, params: &[&[u8]]) { + self.progress = params + .iter() + .map(|p| String::from_utf8_lossy(p).into_owned()) + .collect::>() + .join(";"); + } +} + +#[derive(Default)] +pub struct AgentStatusDetector { + kind: AgentKind, + state: Semantic, + pending_idle_since: Option, + pending_idle_count: u32, +} + +impl AgentStatusDetector { + pub fn detect( + &mut self, + tab_title: &str, + screen: &str, + osc_title: &str, + osc_progress: &str, + ) -> (AgentKind, AgentStatus) { + let osc = if osc_title.is_empty() { tab_title } else { osc_title }; + let input = DetectionInput { + screen, + osc_title: osc, + osc_progress, + }; + let inferred = infer_agents(&input); + let match_ = find_best(self.kind, &inferred, &input); + + if match_.is_none() { + if self.kind == AgentKind::Unknown && inferred.len() == 1 { + self.kind = inferred[0]; + } + if self.kind != AgentKind::Unknown { + self.state = Semantic::Idle; + } + self.pending_idle_since = None; + self.pending_idle_count = 0; + return (self.kind, status_for(self.state, AgentStatus::Idle)); + } + + let (kind, rule) = match_.unwrap(); + self.kind = kind; + if rule.skip_state { + return (self.kind, status_for(self.state, AgentStatus::Idle)); + } + if let Some(next) = self.confirm_idle(rule) { + self.state = next; + } + (self.kind, status_for(self.state, AgentStatus::Idle)) + } + + fn confirm_idle(&mut self, rule: &Rule) -> Option { + if rule.state != Semantic::Idle || self.state != Semantic::Working || rule.visible_idle { + self.pending_idle_since = None; + self.pending_idle_count = 0; + return Some(rule.state); + } + let now = Instant::now(); + if self.pending_idle_since.is_none() { + self.pending_idle_since = Some(now); + self.pending_idle_count = 1; + return None; + } + self.pending_idle_count += 1; + let elapsed = self.pending_idle_since.unwrap().elapsed().as_millis(); + if self.pending_idle_count >= IDLE_CONFIRMATIONS || elapsed >= IDLE_CONFIRM_CAP_MS { + self.pending_idle_since = None; + self.pending_idle_count = 0; + return Some(Semantic::Idle); + } + None + } +} + +struct DetectionInput<'a> { + screen: &'a str, + osc_title: &'a str, + osc_progress: &'a str, +} + +pub fn detect(title: &str, screen: &str, previous: AgentStatus) -> (AgentKind, AgentStatus) { + let mut det = AgentStatusDetector { + kind: AgentKind::Unknown, + state: match previous { + AgentStatus::Running => Semantic::Working, + AgentStatus::Waiting => Semantic::Blocked, + AgentStatus::Completed | AgentStatus::Idle => Semantic::Idle, + }, + pending_idle_since: None, + pending_idle_count: 0, + }; + let (kind, status) = det.detect(title, screen, title, ""); + if matches!(previous, AgentStatus::Running | AgentStatus::Waiting) && status == AgentStatus::Idle { + (kind, AgentStatus::Completed) + } else { + (kind, status) + } +} + +fn status_for(state: Semantic, fallback: AgentStatus) -> AgentStatus { + match state { + Semantic::Blocked => AgentStatus::Waiting, + Semantic::Working => AgentStatus::Running, + Semantic::Idle => AgentStatus::Idle, + Semantic::Unknown => fallback, + } +} + +fn infer_agents(input: &DetectionInput) -> Vec { + let bottom = bottom_lines(input.screen, 20, true); + let hay = format!("{}\n{}\n{bottom}", input.osc_title, input.osc_progress); + if hay.to_ascii_lowercase().contains("action required") { + return vec![AgentKind::Codex]; + } + let named: Vec = MANIFESTS.iter().filter(|m| mentions(&hay, m)).map(|m| m.id).collect(); + if !named.is_empty() { + return named; + } + if regex_is_match(SPINNER_TITLE, input.osc_title) { + return vec![AgentKind::Claude, AgentKind::Codex]; + } + Vec::new() +} + +fn mentions(text: &str, manifest: &Manifest) -> bool { + let lower = text.to_ascii_lowercase(); + std::iter::once(kind_name(manifest.id)) + .chain(manifest.aliases.iter().copied()) + .any(|name| lower.contains(&name.to_ascii_lowercase())) +} + +fn kind_name(kind: AgentKind) -> &'static str { + match kind { + AgentKind::Claude => "claude", + AgentKind::Codex => "codex", + AgentKind::Gemini => "gemini", + AgentKind::Cursor => "cursor", + AgentKind::Copilot => "copilot", + AgentKind::Amp => "amp", + AgentKind::Cline => "cline", + AgentKind::OpenCode => "opencode", + AgentKind::Grok => "grok", + AgentKind::Kimi => "kimi", + AgentKind::Devin => "devin", + AgentKind::Droid => "droid", + AgentKind::Hermes => "hermes", + AgentKind::Kilo => "kilo", + AgentKind::Kiro => "kiro", + AgentKind::Pi => "pi", + AgentKind::Qoder => "qodercli", + AgentKind::Agy => "agy", + AgentKind::Other | AgentKind::Unknown => "", + } +} + +fn find_best(current: AgentKind, inferred: &[AgentKind], input: &DetectionInput) -> Option<(AgentKind, &'static Rule)> { + let mut candidates = Vec::new(); + if current != AgentKind::Unknown { + candidates.push(current); + } + for kind in inferred { + if !candidates.contains(kind) { + candidates.push(*kind); + } + } + let mut best: Option<(AgentKind, &'static Rule)> = None; + for kind in candidates { + let Some(manifest) = MANIFESTS.iter().find(|m| m.id == kind) else { + continue; + }; + if let Some(rule) = evaluate(manifest, input) { + if best.is_none_or(|(_, b)| rule.priority > b.priority) { + best = Some((kind, rule)); + } + } + } + best +} + +fn evaluate(manifest: &Manifest, input: &DetectionInput) -> Option<&'static Rule> { + let mut best = None; + for rule in manifest.rules { + let text = select_region(input, rule.region); + if gate_matches(&rule.gate, &text) && best.is_none_or(|b: &Rule| rule.priority > b.priority) { + best = Some(rule); + } + } + best +} + +fn select_region(input: &DetectionInput, region: Region) -> String { + match region { + Region::OscTitle => input.osc_title.to_string(), + Region::OscProgress => input.osc_progress.to_string(), + Region::AfterLastHorizontalRule => after_last_hr(input.screen), + Region::AfterLastPromptMarker => after_last_prompt(input.screen), + Region::PromptBoxBody => { + let after = after_last_prompt(&after_last_hr(input.screen)); + bottom_lines(&after, 12, false) + } + Region::WholeRecent => input.screen.to_string(), + Region::BottomLines(n) => bottom_lines(input.screen, n, false), + Region::BottomNonEmpty(n) => bottom_lines(input.screen, n, true), + } +} + +fn bottom_lines(text: &str, count: usize, non_empty: bool) -> String { + let lines: Vec<&str> = if non_empty { + text.lines().filter(|l| !l.trim().is_empty()).collect() + } else { + text.lines().collect() + }; + let start = lines.len().saturating_sub(count); + lines[start..].join("\n") +} + +fn after_last_hr(text: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + for i in (0..lines.len()).rev() { + if regex_is_match(r"^\s*[─━═-]{3,}\s*$", lines[i]) { + return lines[i + 1..].join("\n"); + } + } + text.to_string() +} + +fn after_last_prompt(text: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + for i in (0..lines.len()).rev() { + if PROMPT_MARKERS.iter().any(|m| lines[i].contains(m)) { + return lines[i..].join("\n"); + } + } + text.to_string() +} + +fn gate_matches(gate: &Gate, text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + for needle in gate.contains { + if !lower.contains(&needle.to_ascii_lowercase()) { + return false; + } + } + for pat in gate.regex { + if !regex_is_match(pat, text) { + return false; + } + } + if !gate.line_regex.is_empty() { + let lines: Vec<&str> = text.lines().collect(); + for pat in gate.line_regex { + if !lines.iter().any(|line| regex_is_match(pat, line)) { + return false; + } + } + } + if !gate.all.is_empty() && !gate.all.iter().all(|g| gate_matches(g, text)) { + return false; + } + if !gate.any.is_empty() && !gate.any.iter().any(|g| gate_matches(g, text)) { + return false; + } + if gate.not.iter().any(|g| gate_matches(g, text)) { + return false; + } + true +} + +fn regex_is_match(pat: &str, text: &str) -> bool { + thread_local! { + static CACHE: RefCell>> = RefCell::new(HashMap::new()); + } + CACHE.with(|cache| { + let mut map = cache.borrow_mut(); + let re = map + .entry(pat.to_string()) + .or_insert_with(|| Regex::new(pat).ok()) + .as_ref(); + re.is_some_and(|r| r.is_match(text)) + }) +} + +pub fn clickable_tokens(screen: &str) -> Vec { + let mut out = Vec::new(); + for token in screen.split_whitespace() { + let clean = token.trim_matches(|c: char| matches!(c, ',' | ';' | ')' | '(' | '[' | ']' | '"' | '\'' | '`')); + if clean.starts_with("http://") || clean.starts_with("https://") { + out.push(Clickable::Url(clean.to_string())); + } else if looks_like_path(clean) { + out.push(Clickable::Path(clean.to_string())); + } + } + out.dedup(); + out.into_iter().take(8).collect() +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Clickable { + Url(String), + Path(String), +} + +fn looks_like_path(token: &str) -> bool { + if token.len() < 3 || token.contains("://") { + return false; + } + (token.contains('/') || token.contains('.')) + && token + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '.' | '_' | '-' | '~')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_claude_waiting() { + let (kind, status) = detect( + "claude", + "do you want to proceed?\nesc to cancel\n1. yes", + AgentStatus::Running, + ); + assert_eq!(kind, AgentKind::Claude); + assert_eq!(status, AgentStatus::Waiting); + } + + #[test] + fn detects_codex_working() { + let (kind, status) = detect("codex", "press enter to confirm or esc to cancel", AgentStatus::Idle); + assert_eq!(kind, AgentKind::Codex); + assert_eq!(status, AgentStatus::Waiting); + } + + #[test] + fn extracts_urls_and_paths() { + let tokens = clickable_tokens("see https://example.com and src/app.rs please"); + assert!(tokens.contains(&Clickable::Url("https://example.com".into()))); + assert!(tokens.contains(&Clickable::Path("src/app.rs".into()))); + } +} diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 59546a3d..f9681086 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -164,7 +164,8 @@ pub struct TermSession { pub id: String, pub title: String, pub profile_id: String, - pub parser: vt100::Parser, + pub parser: vt100::Parser, + pub detector: crate::detector::AgentStatusDetector, pub search_open: bool, pub search_ix: usize, pub search_query: String, @@ -181,7 +182,8 @@ impl TermSession { id, title, profile_id, - parser: vt100::Parser::new(32, 120, 10_000), + parser: vt100::Parser::new_with_callbacks(32, 120, 10_000, crate::detector::OscSink::default()), + detector: crate::detector::AgentStatusDetector::default(), search_open: false, search_ix: 0, search_query: String::new(), @@ -210,8 +212,12 @@ impl TermSession { fn detect_agent(&mut self) { let text = self.parser.screen().contents(); + let osc = self.parser.callbacks(); let prev = self.agent; - let (kind, status) = crate::detector::detect(&self.title, &text, prev); + let (kind, mut status) = self.detector.detect(&self.title, &text, &osc.title, &osc.progress); + if matches!(prev, AgentStatus::Running | AgentStatus::Waiting) && status == AgentStatus::Idle { + status = AgentStatus::Completed; + } if kind != AgentKind::Unknown { self.agent_kind = kind; } @@ -333,6 +339,7 @@ pub struct OverlayState { pub git_selected_commit: Option, pub git_large_revealed: HashSet, pub review_comments: Vec, + pub review_line: Option<(String, String)>, pub branches: Vec, pub fuzzy_files: Vec, pub onboarding: bool, diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index 1695af74..14fb66d3 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -6,8 +6,9 @@ use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, Style use gpui_component::{Disableable, Selectable}; use crate::app::{extract_file_hunk, file_status_badge, AppView}; +use crate::backend; use crate::diff::{self, DiffLineKind}; -use crate::state::{DiffPreviewMode, GitDiffTab}; +use crate::state::{DialogKind, DiffPreviewMode, GitDiffTab}; pub fn render_panel(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); @@ -520,11 +521,50 @@ pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Cont }), ) .into_any_element() + } else if selected.as_ref().is_some_and(|p| backend::is_image(p)) { + let worktree = app.data.current_ws().map(|w| w.worktree.clone()).unwrap_or_default(); + let path = + std::path::PathBuf::from(worktree).join(selected.as_deref().unwrap_or_default()); + gpui::img(path).w_full().max_h(px(480.)).into_any_element() } else { - diff_view(&diff, mode, theme.muted_foreground).into_any_element() + v_flex() + .gap_2() + .child(diff_stats(&diff)) + .child(diff_view( + &diff, + mode, + theme.muted_foreground, + selected.clone().unwrap_or_default(), + &view, + )) + .child(review_composer(app, cx)) + .into_any_element() }, )), - ), + ) + .when(!app.data.overlay.review_comments.is_empty(), |el| { + el.child( + div().id("review-fab").absolute().bottom_4().right_4().child( + Button::new("open-review") + .primary() + .small() + .label(format!( + "{} ({})", + app.t("reviewQueue"), + app.data.overlay.review_comments.len() + )) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = Some(DialogKind::ReviewQueue); + cx.notify(); + }); + } + }), + ), + ) + }), ) .into_any_element() } @@ -628,25 +668,115 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { .into_any_element() } -fn diff_view(diff: &str, mode: DiffPreviewMode, muted: gpui::Hsla) -> impl IntoElement { +fn diff_stats(diff: &str) -> impl IntoElement { + let adds = diff + .lines() + .filter(|l| l.starts_with('+') && !l.starts_with("+++")) + .count(); + let dels = diff + .lines() + .filter(|l| l.starts_with('-') && !l.starts_with("---")) + .count(); + h_flex() + .gap_2() + .text_xs() + .font_family("monospace") + .child( + div() + .text_color(gpui::hsla(0.38, 0.7, 0.45, 1.)) + .child(format!("+{adds}")), + ) + .child( + div() + .text_color(gpui::hsla(0.02, 0.75, 0.5, 1.)) + .child(format!("-{dels}")), + ) +} + +fn review_composer(app: &AppView, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let selected = app + .data + .overlay + .review_line + .as_ref() + .map(|(_, line)| line.clone()) + .unwrap_or_default(); + v_flex() + .gap_1() + .pt_2() + .border_t_1() + .border_color(cx.theme().border) + .when(!selected.is_empty(), |el| { + el.child(div().text_xs().font_family("monospace").child(selected)) + }) + .child(Input::new(&app.inputs.review_comment)) + .child( + Button::new("add-review") + .small() + .primary() + .label(app.t("reviewQueue")) + .disabled(app.data.overlay.review_line.is_none()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.add_review_comment(window, cx); + cx.notify(); + }); + } + }), + ) +} + +fn diff_view( + diff: &str, + mode: DiffPreviewMode, + muted: gpui::Hsla, + file: String, + view: &gpui::Entity, +) -> impl IntoElement { if diff.trim().is_empty() { return div().text_color(muted).child("").into_any_element(); } if mode == DiffPreviewMode::Split { - return split_diff_view(diff, muted).into_any_element(); + return split_diff_view(diff, muted, file, view).into_any_element(); } v_flex() .id("diff-lines") .gap_0() .font_family("monospace") .text_sm() - .children(diff.lines().map(|line| unified_line(line, muted))) + .children( + diff.lines() + .enumerate() + .map(|(ix, line)| unified_line(line, muted, file.clone(), ix, view)), + ) .into_any_element() } -fn unified_line(line: &str, muted: gpui::Hsla) -> impl IntoElement { +fn unified_line( + line: &str, + muted: gpui::Hsla, + file: String, + ix: usize, + view: &gpui::Entity, +) -> impl IntoElement { let (bg, color) = line_colors(line, muted); - div().px_2().bg(bg).text_color(color).child(line.to_string()) + let text = line.to_string(); + let view = view.clone(); + div() + .id(crate::ui::eid(format!("udiff-{ix}"))) + .px_2() + .bg(bg) + .text_color(color) + .on_click(move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.review_line = Some((file.clone(), text.clone())); + cx.notify(); + }); + }) + .child(line.to_string()) } fn line_colors(line: &str, muted: gpui::Hsla) -> (gpui::Hsla, gpui::Hsla) { @@ -668,7 +798,7 @@ fn kind_colors(kind: DiffLineKind, muted: gpui::Hsla) -> (gpui::Hsla, gpui::Hsla } } -fn split_diff_view(diff: &str, muted: gpui::Hsla) -> impl IntoElement { +fn split_diff_view(diff: &str, muted: gpui::Hsla, file: String, view: &gpui::Entity) -> impl IntoElement { v_flex() .id("diff-split") .gap_0() @@ -678,9 +808,9 @@ fn split_diff_view(diff: &str, muted: gpui::Hsla) -> impl IntoElement { h_flex() .id(crate::ui::eid(format!("split-row-{ix}"))) .w_full() - .child(split_cell(row.left, muted, ix, "l")) + .child(split_cell(row.left, muted, ix, "l", file.clone(), view)) .child(div().w(px(1.)).h_full().bg(gpui::hsla(0., 0., 0.5, 0.2))) - .child(split_cell(row.right, muted, ix, "r")) + .child(split_cell(row.right, muted, ix, "r", file.clone(), view)) })) } @@ -689,6 +819,8 @@ fn split_cell( muted: gpui::Hsla, ix: usize, side: &'static str, + file: String, + view: &gpui::Entity, ) -> impl IntoElement { let (kind, text) = match cell { Some((kind, text)) => (Some(kind), text), @@ -697,6 +829,8 @@ fn split_cell( let (bg, color) = kind .map(|k| kind_colors(k, muted)) .unwrap_or((gpui::hsla(0., 0., 0., 0.), muted)); + let view = view.clone(); + let shown = if text.is_empty() { " ".to_string() } else { text.clone() }; div() .id(crate::ui::eid(format!("split-{side}-{ix}"))) .flex_1() @@ -704,5 +838,13 @@ fn split_cell( .px_2() .bg(bg) .text_color(color) - .child(if text.is_empty() { " ".to_string() } else { text }) + .on_click(move |_, _, cx| { + if !text.is_empty() { + view.update(cx, |app, cx| { + app.data.overlay.review_line = Some((file.clone(), text.clone())); + cx.notify(); + }); + } + }) + .child(shown) } diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 4183d3f3..0bba8bcf 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -799,9 +799,25 @@ impl SettingsView { self.t("openUpdatePage") }) .on_click({ + let view = view.clone(); let url = self.latest_url.clone(); - move |_, _, _| { - let _ = open::that(&url); + let has_update = self.latest_version.is_some(); + move |_, _, cx| { + if has_update { + view.update(cx, |this, cx| { + this.update_status = this.t("checkForUpdates"); + match crate::updater::download_and_install(this.prefs.accept_beta) { + Ok(path) => this.update_status = path, + Err(err) => { + this.update_status = err; + let _ = open::that(&url); + } + } + cx.notify(); + }); + } else { + let _ = open::that(&url); + } } }), ), diff --git a/src-gpui/src/updater.rs b/src-gpui/src/updater.rs index 25babfd8..3e79774a 100644 --- a/src-gpui/src/updater.rs +++ b/src-gpui/src/updater.rs @@ -1,8 +1,11 @@ +use std::collections::HashMap; +use std::io::Write; + use serde::Deserialize; -const GITHUB_RELEASES_API: &str = - "https://api.github.com/repos/AkaraChen/2code/releases?per_page=20"; +const GITHUB_RELEASES_API: &str = "https://api.github.com/repos/AkaraChen/2code/releases?per_page=20"; const GITHUB_RELEASES_PAGE: &str = "https://github.com/AkaraChen/2code/releases"; +const GITHUB_RELEASE_DOWNLOAD_BASE: &str = "https://github.com/AkaraChen/2code/releases/download"; #[derive(Debug, Clone)] pub struct UpdateInfo { @@ -22,6 +25,25 @@ struct GithubRelease { prerelease: bool, #[serde(default)] html_url: String, + #[serde(default)] + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GithubAsset { + name: String, + browser_download_url: String, +} + +#[derive(Debug, Deserialize)] +struct LatestJson { + #[serde(default)] + platforms: HashMap, +} + +#[derive(Debug, Deserialize)] +struct LatestPlatform { + url: String, } pub fn current_version() -> &'static str { @@ -47,8 +69,7 @@ pub fn check_for_update(accept_beta: bool) -> Result { .into_json() .map_err(|e| e.to_string())?; - let release = pick_release(&releases, accept_beta) - .ok_or_else(|| "no matching GitHub release".to_string())?; + let release = pick_release(&releases, accept_beta).ok_or_else(|| "no matching GitHub release".to_string())?; let latest = release.tag_name.trim_start_matches('v').to_string(); let current = current_version().to_string(); Ok(UpdateInfo { @@ -67,9 +88,7 @@ pub fn check_for_update(accept_beta: bool) -> Result { fn pick_release(releases: &[GithubRelease], accept_beta: bool) -> Option<&GithubRelease> { if accept_beta { if let Some(beta) = releases.iter().find(|release| { - !release.draft - && release.prerelease - && release.tag_name.to_ascii_lowercase().contains("beta") + !release.draft && release.prerelease && release.tag_name.to_ascii_lowercase().contains("beta") }) { return Some(beta); } @@ -119,6 +138,101 @@ fn is_newer(latest: &str, current: &str) -> bool { latest_parts > current_parts } +pub fn download_and_install(accept_beta: bool) -> Result { + let info = check_for_update(accept_beta)?; + if !info.available { + return Err("no update available".into()); + } + let url = asset_url(accept_beta)?; + let name = url + .rsplit('/') + .next() + .unwrap_or("2code-update") + .split('?') + .next() + .unwrap_or("2code-update"); + let dest = std::env::temp_dir().join(format!("{}-{}", info.latest_version, name)); + let mut request = ureq::get(&url).set("user-agent", "2code-gpui"); + if let Some(token) = gh_auth_token() { + request = request.set("authorization", &format!("Bearer {token}")); + } + let response = request.call().map_err(|e| e.to_string())?; + let mut file = std::fs::File::create(&dest).map_err(|e| e.to_string())?; + let mut reader = response.into_reader(); + std::io::copy(&mut reader, &mut file).map_err(|e| e.to_string())?; + file.flush().map_err(|e| e.to_string())?; + open::that(&dest).map_err(|e| e.to_string())?; + Ok(dest.display().to_string()) +} + +fn asset_url(accept_beta: bool) -> Result { + let mut request = ureq::get(GITHUB_RELEASES_API) + .set("accept", "application/vnd.github+json") + .set("user-agent", "2code-gpui"); + if let Some(token) = gh_auth_token() { + request = request.set("authorization", &format!("Bearer {token}")); + } + let releases: Vec = request + .call() + .map_err(|e| e.to_string())? + .into_json() + .map_err(|e| e.to_string())?; + let release = pick_release(&releases, accept_beta).ok_or_else(|| "no matching GitHub release".to_string())?; + let tag = release.tag_name.replace('/', "%2F"); + let latest_url = format!("{GITHUB_RELEASE_DOWNLOAD_BASE}/{tag}/latest.json"); + if let Ok(url) = url_from_latest_json(&latest_url) { + return Ok(url); + } + let needle = platform_asset_needle(); + release + .assets + .iter() + .find(|a| needle.iter().any(|n| a.name.to_ascii_lowercase().contains(n))) + .map(|a| a.browser_download_url.clone()) + .ok_or_else(|| "no installer asset for this platform".into()) +} + +fn url_from_latest_json(url: &str) -> Result { + let mut request = ureq::get(url).set("user-agent", "2code-gpui"); + if let Some(token) = gh_auth_token() { + request = request.set("authorization", &format!("Bearer {token}")); + } + let latest: LatestJson = request + .call() + .map_err(|e| e.to_string())? + .into_json() + .map_err(|e| e.to_string())?; + let key = platform_latest_key(); + latest + .platforms + .get(key) + .or_else(|| latest.platforms.values().next()) + .map(|p| p.url.clone()) + .ok_or_else(|| "latest.json has no platform url".into()) +} + +fn platform_latest_key() -> &'static str { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "darwin-aarch64" + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + "darwin-x86_64" + } else if cfg!(target_os = "windows") { + "windows-x86_64" + } else { + "linux-x86_64" + } +} + +fn platform_asset_needle() -> &'static [&'static str] { + if cfg!(target_os = "macos") { + &["dmg", "darwin", "macos", "app.tar.gz"] + } else if cfg!(target_os = "windows") { + &["msi", "exe", "windows"] + } else { + &["appimage", "deb", "rpm", "linux"] + } +} + #[cfg(test)] mod tests { use super::*; @@ -139,12 +253,14 @@ mod tests { draft: false, prerelease: false, html_url: String::new(), + assets: Vec::new(), }, GithubRelease { tag_name: "v3.2.0-beta.1".into(), draft: false, prerelease: true, html_url: String::new(), + assets: Vec::new(), }, ]; assert_eq!(pick_release(&releases, true).unwrap().tag_name, "v3.2.0-beta.1"); From c75e3971082f35e698e8236f2371eecf08583204 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 06:04:56 +0000 Subject: [PATCH 08/50] Close GPUI keyboard and start-entry inventory gaps. Bind Esc to dismiss overlays, wire palette and terminal-search keys, add sidebar arrow navigation, show file-type icons, expand the markdown toolbar, and make ./start.sh / bun start launch the GPUI shell. Co-authored-by: AkaraChen --- README.md | 1 + justfile | 2 +- package.json | 3 +- src-gpui/README.md | 7 +- src-gpui/src/app.rs | 199 ++++++++++++++++++++++++++++++++- src-gpui/src/state.rs | 78 +++++++++++++ src-gpui/src/ui/file_icons.rs | 128 +++++++++++++++++++++ src-gpui/src/ui/file_tree.rs | 13 +-- src-gpui/src/ui/file_viewer.rs | 72 ++++++------ src-gpui/src/ui/git.rs | 6 +- src-gpui/src/ui/mod.rs | 1 + src-gpui/src/ui/palette.rs | 25 ++++- src-gpui/src/ui/shell.rs | 6 +- src-gpui/src/ui/sidebar.rs | 50 ++++++++- src-gpui/src/ui/terminal.rs | 81 ++++++-------- src-gpui/src/ui/workspace.rs | 2 +- start.sh | 8 +- 17 files changed, 570 insertions(+), 112 deletions(-) create mode 100644 src-gpui/src/ui/file_icons.rs diff --git a/README.md b/README.md index 45a3e119..3f8b358d 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ The legacy Tauri bundle is still `bun tauri build`. | Command | Description | | --- | --- | +| `just start` / `./start.sh` | Run the GPUI desktop app | | `just gpui` | Run the GPUI desktop app | | `just gpui-check` | Type-check the GPUI crate | | `cd src-tauri && cargo test` | Run Rust domain tests | diff --git a/justfile b/justfile index d9935734..4011952e 100644 --- a/justfile +++ b/justfile @@ -9,7 +9,7 @@ start: just gpui tauri-dev: - ./start.sh + bun tauri dev fmt: fama "./src/**/*.{ts,tsx}" diff --git a/package.json b/package.json index 143ef834..54cac50e 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "open:dmg": "bash ./scripts/open-dmg.sh", "preview": "vite preview", "tauri": "tauri", - "start": "tauri dev", + "start": "just gpui", + "tauri:dev": "tauri dev", "lint": "eslint --fix", "lint:check": "eslint . --max-warnings=0", "test": "bun run paraglide:compile && vitest run", diff --git a/src-gpui/README.md b/src-gpui/README.md index 8f5b88cc..48716127 100644 --- a/src-gpui/README.md +++ b/src-gpui/README.md @@ -6,12 +6,15 @@ Native desktop rewrite of 2code using [GPUI](https://gpui.rs) and [gpui-componen ```bash # from repo root -cd src-gpui && cargo run - +just start # or just gpui +# or +cd src-gpui && cargo run ``` +`./start.sh` and `bun start` also launch this GPUI shell. The legacy Tauri path is `just tauri-dev` / `bun tauri:dev`. + Requires Rust 1.87+ (GPUI). On Linux install `libxkbcommon`, Vulkan, and Wayland/X11 development libraries. ## Window contract diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index bec56b5c..55f141e9 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -31,12 +31,15 @@ pub struct SaveFile; #[derive(Clone, PartialEq, Default, Debug, Action)] #[action(namespace = twocode, no_json)] pub struct CommitChanges; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct DismissOverlay; use crate::backend::{self, Backend}; use crate::i18n::{self, Locale}; use crate::prefs::{term_theme_by_name, Prefs, ThemePref}; use crate::state::{ AgentStatus, AppData, ContextMenu, DialogKind, DiffPreviewMode, GitDiffTab, NotesStatus, OpenFileTab, OverlayState, - Route, SettingsTab, SidebarMode, TermSession, ToastKind, TreeNode, UnifiedTab, Workspace, + Route, SettingsTab, SidebarMode, SidebarNavItem, TermSession, ToastKind, TreeNode, UnifiedTab, Workspace, }; use crate::ui; use gpui_component::input::{Input, InputState}; @@ -1351,8 +1354,13 @@ impl AppView { return; }; let q = self.inputs.palette.read(cx).value().to_string(); + if q == self.data.overlay.palette_query { + return; + } + self.data.overlay.palette_query = q.clone(); if q.trim().is_empty() { self.data.overlay.palette_results.clear(); + self.data.overlay.palette_index = 0; return; } self.data.overlay.palette_results = self.backend.search_files(&profile_id, &q).unwrap_or_default(); @@ -1591,8 +1599,183 @@ impl AppView { KeyBinding::new("ctrl-s", SaveFile, None), KeyBinding::new("cmd-enter", CommitChanges, None), KeyBinding::new("ctrl-enter", CommitChanges, None), + KeyBinding::new("escape", DismissOverlay, None), ]); } + + /// Close the topmost overlay. Returns true when something was dismissed. + pub fn dismiss_overlay(&mut self) -> bool { + if self.data.overlay.context_menu.take().is_some() { + return true; + } + if self.data.overlay.renaming_path.take().is_some() { + return true; + } + if self.data.overlay.palette_open { + self.data.overlay.palette_open = false; + self.data.overlay.palette_results.clear(); + self.data.overlay.palette_query.clear(); + return true; + } + if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + if term.search_open { + term.search_open = false; + term.search_query.clear(); + term.search_ix = 0; + return true; + } + } + if self.data.overlay.git_diff_open { + self.data.overlay.git_diff_open = false; + return true; + } + if self.data.overlay.dialog.take().is_some() { + self.data.overlay.dialog_error = None; + self.data.overlay.dialog_busy = false; + return true; + } + if self.data.overlay.debug_open { + self.data.overlay.debug_open = false; + return true; + } + if self.data.overlay.onboarding { + self.data.overlay.onboarding = false; + return true; + } + false + } + + pub fn move_palette(&mut self, delta: i32) { + let len = self.data.overlay.palette_results.len(); + if len == 0 { + return; + } + let cur = self.data.overlay.palette_index as i32; + self.data.overlay.palette_index = (cur + delta).rem_euclid(len as i32) as usize; + } + + pub fn sidebar_nav_items(&self) -> Vec { + crate::state::collect_sidebar_nav_items( + &self.data.projects, + &self.data.groups, + &self.data.prefs.collapsed_groups, + &self.data.overlay.expanded_projects, + self.data.current_project.as_deref(), + ) + } + + pub fn move_sidebar_nav(&mut self, delta: i32) -> bool { + let items = self.sidebar_nav_items(); + if items.is_empty() { + return false; + } + let cur = self + .data + .overlay + .sidebar_nav + .as_ref() + .and_then(|cur| items.iter().position(|item| item == cur)) + .unwrap_or_else(|| { + if let Some(pid) = &self.data.current_profile { + items + .iter() + .position( + |item| matches!(item, SidebarNavItem::Profile { profile_id, .. } if profile_id == pid), + ) + .unwrap_or(0) + } else { + 0 + } + }); + let next = (cur as i32 + delta).clamp(0, items.len() as i32 - 1) as usize; + self.data.overlay.sidebar_nav = Some(items[next].clone()); + true + } + + pub fn activate_sidebar_nav(&mut self) -> bool { + let item = self + .data + .overlay + .sidebar_nav + .clone() + .or_else(|| self.sidebar_nav_items().into_iter().next()); + let Some(item) = item else { + return false; + }; + match item { + SidebarNavItem::Home => { + self.data.route = Route::Home; + self.data.current_project = None; + self.data.current_profile = None; + } + SidebarNavItem::Project(id) => { + if let Some(profile) = self.data.default_profile_of(&id) { + self.open_profile(&id, &profile.id); + } + } + SidebarNavItem::Profile { project_id, profile_id } => { + self.open_profile(&project_id, &profile_id); + } + } + true + } + + pub fn handle_overlay_key(&mut self, key: &str, shift: bool, window: &mut Window, cx: &mut Context) -> bool { + if self.data.overlay.palette_open { + return match key { + "up" => { + self.move_palette(-1); + true + } + "down" => { + self.move_palette(1); + true + } + "enter" => { + self.open_palette_selection(window, cx); + true + } + _ => false, + }; + } + if self + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .is_some_and(|t| t.search_open) + { + return match key { + "enter" => { + self.cycle_term_search(cx, !shift); + true + } + _ => false, + }; + } + if self.data.overlay.renaming_path.is_some() && key == "enter" { + self.commit_rename_path(cx); + return true; + } + false + } + + pub fn handle_sidebar_key(&mut self, key: &str) -> bool { + if self.data.overlay.palette_open + || self.data.overlay.dialog.is_some() + || self.data.overlay.git_diff_open + || self.data.overlay.context_menu.is_some() + || self.data.overlay.renaming_path.is_some() + || self.data.overlay.sidebar_resize_focus.is_some() + { + return false; + } + match key { + "up" => self.move_sidebar_nav(-1), + "down" => self.move_sidebar_nav(1), + "enter" | "space" => self.activate_sidebar_nav(), + _ => false, + } + } } fn agent_rank(status: AgentStatus) -> u8 { @@ -1688,10 +1871,7 @@ impl gpui::Render for AppView { self.sync_pty_size(window); self.sync_notes_input(window, cx); if self.data.overlay.palette_open { - let q = self.inputs.palette.read(cx).value().to_string(); - if self.data.overlay.palette_results.is_empty() && !q.is_empty() { - self.search_palette(cx); - } + self.search_palette(cx); } if let Some(ws) = self.data.current_ws() { if let Some(UnifiedTab::File { index }) = ws.active { @@ -1725,6 +1905,10 @@ impl gpui::Render for AppView { .on_action(cx.listener(|this, _: &OpenPalette, _, cx| { if this.data.route == Route::Workspace { this.data.overlay.palette_open = !this.data.overlay.palette_open; + if !this.data.overlay.palette_open { + this.data.overlay.palette_results.clear(); + this.data.overlay.palette_query.clear(); + } cx.notify(); } })) @@ -1772,6 +1956,11 @@ impl gpui::Render for AppView { this.commit_selected(cx); cx.notify(); })) + .on_action(cx.listener(|this, _: &DismissOverlay, _, cx| { + if this.dismiss_overlay() { + cx.notify(); + } + })) .child(ui::shell::render(self, window, cx)) } } diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index f9681086..a419e437 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -117,6 +117,13 @@ pub enum ContextMenu { NewTerminal, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SidebarNavItem { + Home, + Project(String), + Profile { project_id: String, profile_id: String }, +} + #[derive(Debug, Clone)] pub struct Toast { pub id: u64, @@ -295,6 +302,13 @@ pub enum NotesStatus { } impl Workspace { + pub fn active_terminal(&self) -> Option<&TermSession> { + match self.active { + Some(UnifiedTab::Terminal { index }) => self.terminals.get(index), + _ => None, + } + } + pub fn active_terminal_mut(&mut self) -> Option<&mut TermSession> { match self.active { Some(UnifiedTab::Terminal { index }) => self.terminals.get_mut(index), @@ -329,6 +343,7 @@ pub struct OverlayState { pub palette_open: bool, pub palette_results: Vec, pub palette_index: usize, + pub palette_query: String, pub git_diff_open: bool, pub git_diff_tab: GitDiffTab, pub git_diff_mode: DiffPreviewMode, @@ -355,6 +370,7 @@ pub struct OverlayState { pub sidebar_drag: Option<(f32, f32)>, pub profile_sidebar_drag: Option<(f32, f32)>, pub sidebar_resize_focus: Option, + pub sidebar_nav: Option, pub drag_project: Option, pub drag_file: Option, pub renaming_path: Option, @@ -429,3 +445,65 @@ impl AppData { .retain(|t| t.created.elapsed().as_secs() < if t.action.is_some() { 12 } else { 5 }); } } + +pub fn collect_sidebar_nav_items( + projects: &[ProjectWithProfiles], + groups: &[ProjectGroup], + collapsed_groups: &[String], + expanded_projects: &HashSet, + current_project: Option<&str>, +) -> Vec { + let mut items = Vec::new(); + if projects.is_empty() { + items.push(SidebarNavItem::Home); + return items; + } + let mut push_project = |project: &ProjectWithProfiles| { + items.push(SidebarNavItem::Project(project.id.clone())); + let extras: Vec<_> = project.profiles.iter().filter(|p| !p.is_default).collect(); + let selected = current_project == Some(project.id.as_str()); + let expanded = expanded_projects.contains(&project.id) || extras.is_empty() || selected; + if expanded { + for profile in &project.profiles { + items.push(SidebarNavItem::Profile { + project_id: project.id.clone(), + profile_id: profile.id.clone(), + }); + } + } + }; + for project in projects.iter().filter(|p| p.pinned_at.is_some()) { + push_project(project); + } + for group in groups { + if collapsed_groups.contains(&group.id) { + continue; + } + for project in projects + .iter() + .filter(|p| p.group_id.as_deref() == Some(group.id.as_str()) && p.pinned_at.is_none()) + { + push_project(project); + } + } + for project in projects + .iter() + .filter(|p| p.pinned_at.is_none() && p.group_id.is_none()) + { + push_project(project); + } + items +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_sidebar_starts_at_home() { + assert_eq!( + collect_sidebar_nav_items(&[], &[], &[], &HashSet::new(), None), + vec![SidebarNavItem::Home] + ); + } +} diff --git a/src-gpui/src/ui/file_icons.rs b/src-gpui/src/ui/file_icons.rs new file mode 100644 index 00000000..58aca102 --- /dev/null +++ b/src-gpui/src/ui/file_icons.rs @@ -0,0 +1,128 @@ +use gpui_component::IconName; + +/// Language-colored file-type icon used by the tree, tabs, palette, and Git list. +/// Pierre's SVG sprite is not available in GPUI; this maps the same extensions +/// onto Lucide names plus a token color so types stay distinguishable. +pub fn file_icon(path: &str, is_dir: bool, expanded: bool) -> IconName { + if is_dir { + return if expanded { + IconName::FolderOpen + } else { + IconName::Folder + }; + } + match file_kind(path) { + FileKind::Markdown | FileKind::Text => IconName::BookOpen, + FileKind::Config => IconName::Settings, + FileKind::Git => IconName::GitHub, + FileKind::Shell => IconName::SquareTerminal, + FileKind::Image => IconName::GalleryVerticalEnd, + FileKind::Code => IconName::File, + FileKind::Other => IconName::File, + } +} + +pub fn file_icon_color(path: &str, is_dir: bool) -> u32 { + if is_dir { + return 0xc9a227; + } + match file_kind(path) { + FileKind::Markdown => 0x519aba, + FileKind::Text => 0xa0a0a0, + FileKind::Config => 0xcbcb41, + FileKind::Git => 0xf05032, + FileKind::Shell => 0x89e051, + FileKind::Image => 0xa074c4, + FileKind::Code => code_color(path), + FileKind::Other => 0x8b949e, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FileKind { + Markdown, + Text, + Config, + Git, + Shell, + Image, + Code, + Other, +} + +pub(crate) fn file_kind(path: &str) -> FileKind { + let name = std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path) + .to_ascii_lowercase(); + if matches!( + name.as_str(), + ".gitignore" | ".gitattributes" | ".gitmodules" | ".gitkeep" + ) { + return FileKind::Git; + } + let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or(""); + match ext { + "md" | "mdx" | "markdown" => FileKind::Markdown, + "txt" | "rst" | "rtf" | "log" => FileKind::Text, + "json" | "jsonc" | "toml" | "yml" | "yaml" | "ini" | "env" | "editorconfig" => FileKind::Config, + "sh" | "zsh" | "bash" | "fish" | "ps1" | "bat" | "cmd" => FileKind::Shell, + "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "avif" => FileKind::Image, + "rs" | "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "py" | "go" | "java" | "kt" | "c" | "cc" | "cpp" + | "cxx" | "h" | "hpp" | "cs" | "rb" | "php" | "swift" | "scala" | "lua" | "zig" | "nim" | "ex" | "exs" + | "erl" | "hs" | "ml" | "clj" | "dart" | "vue" | "svelte" | "css" | "scss" | "less" | "html" | "htm" + | "xml" | "sql" | "graphql" | "proto" => FileKind::Code, + _ => FileKind::Other, + } +} + +fn code_color(path: &str) -> u32 { + let ext = std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + match ext.as_str() { + "rs" => 0xdea584, + "ts" | "tsx" => 0x3178c6, + "js" | "jsx" | "mjs" | "cjs" => 0xf1e05a, + "py" => 0x3572a5, + "go" => 0x00add8, + "java" | "kt" => 0xb07219, + "c" | "h" => 0x555555, + "cc" | "cpp" | "cxx" | "hpp" => 0xf34b7d, + "css" | "scss" | "less" => 0x563d7c, + "html" | "htm" => 0xe34c26, + "vue" => 0x41b883, + "svelte" => 0xff3e00, + "sql" => 0xe38c00, + _ => 0x4fc1ff, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn directories_use_folder_kind_colors() { + assert_eq!(file_icon_color("src", true), 0xc9a227); + } + + #[test] + fn extensions_map_to_kinds() { + assert_eq!(file_kind("README.md"), FileKind::Markdown); + assert_eq!(file_kind("Cargo.toml"), FileKind::Config); + assert_eq!(file_kind(".gitignore"), FileKind::Git); + assert_eq!(file_kind("setup.sh"), FileKind::Shell); + assert_eq!(file_kind("logo.png"), FileKind::Image); + assert_eq!(file_kind("main.rs"), FileKind::Code); + assert_eq!(file_kind("notes.unknown"), FileKind::Other); + } + + #[test] + fn rust_and_typescript_use_distinct_colors() { + assert_ne!(file_icon_color("lib.rs", false), file_icon_color("app.ts", false)); + } +} diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index 250d2c25..00d6bd5d 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -176,16 +176,9 @@ fn node_view( } }) .child( - Icon::new(if node.is_dir { - if node.expanded { - IconName::FolderOpen - } else { - IconName::Folder - } - } else { - IconName::File - }) - .w(px(13.)), + Icon::new(crate::ui::file_icons::file_icon(path, node.is_dir, node.expanded)) + .w(px(13.)) + .text_color(gpui::rgb(crate::ui::file_icons::file_icon_color(path, node.is_dir))), ) .child(if renaming { h_flex() diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 7e9a3185..39a5e3cc 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -39,12 +39,19 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .gap_1() .border_b_1() .border_color(theme.border) - .child(md_btn("md-h1", "H1", "# ", &view)) - .child(md_btn("md-h2", "H2", "## ", &view)) - .child(md_btn("md-h3", "H3", "### ", &view)) - .child(md_btn("md-b", "B", "**", &view)) - .child(md_btn("md-i", "I", "*", &view)) - .child(md_btn("md-code", "`", "`", &view)) + .child(md_btn("md-h1", "H1", "# ", "", &view)) + .child(md_btn("md-h2", "H2", "## ", "", &view)) + .child(md_btn("md-h3", "H3", "### ", "", &view)) + .child(md_btn("md-b", "B", "**", "**", &view)) + .child(md_btn("md-i", "I", "*", "*", &view)) + .child(md_btn("md-s", "S", "~~", "~~", &view)) + .child(md_btn("md-code", "`", "`", "`", &view)) + .child(md_btn("md-pre", "", "```\n", "\n```", &view)) + .child(md_btn("md-ul", "•", "- ", "", &view)) + .child(md_btn("md-ol", "1.", "1. ", "", &view)) + .child(md_btn("md-q", ">", "> ", "", &view)) + .child(md_btn("md-link", "[]", "[", "](url)", &view)) + .child(md_btn("md-img", "img", "![", "](src)", &view)) .child( Button::new("md-save") .xsmall() @@ -65,12 +72,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) h_flex() .flex_1() .min_h_0() - .child( - div() - .flex_1() - .h_full() - .child(Input::new(&app.inputs.file_editor)), - ) + .child(div().flex_1().h_full().child(Input::new(&app.inputs.file_editor))) .child( div() .flex_1() @@ -78,12 +80,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .p_3() .border_l_1() .border_color(theme.border) - .child(TextView::markdown( - "md-preview", - file.draft.clone(), - window, - cx, - )), + .child(TextView::markdown("md-preview", file.draft.clone(), window, cx)), ), ) .into_any_element(); @@ -120,11 +117,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .into_any_element() } -fn preview_pane( - _app: &AppView, - file: &crate::state::OpenFileTab, - cx: &mut Context, -) -> impl IntoElement { +fn preview_pane(_app: &AppView, file: &crate::state::OpenFileTab, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); let view = cx.entity(); let path = file.path.clone(); @@ -143,18 +136,13 @@ fn preview_pane( h_flex() .gap_2() .child(div().text_xs().child(file.preview_kind.clone())) - .child( - Button::new("open-external") - .xsmall() - .label("Open") - .on_click({ - let view = view.clone(); - let path = path.clone(); - move |_, _, cx| { - view.update(cx, |app, _| app.open_external(&path)); - } - }), - ), + .child(Button::new("open-external").xsmall().label("Open").on_click({ + let view = view.clone(); + let path = path.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_external(&path)); + } + })), ), ) .child(preview_body(file, cx)) @@ -221,7 +209,13 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> .into_any_element() } -fn md_btn(id: &'static str, label: &'static str, wrap: &'static str, view: &gpui::Entity) -> impl IntoElement { +fn md_btn( + id: &'static str, + label: &'static str, + prefix: &'static str, + suffix: &'static str, + view: &gpui::Entity, +) -> impl IntoElement { let view = view.clone(); Button::new(id) .ghost() @@ -230,7 +224,11 @@ fn md_btn(id: &'static str, label: &'static str, wrap: &'static str, view: &gpui .on_click(move |_, window, cx| { view.update(cx, |app, cx| { let mut text = app.inputs.file_editor.read(cx).value().to_string(); - text.push_str(wrap); + if !text.is_empty() && !text.ends_with('\n') && (prefix.ends_with(' ') || prefix.ends_with('\n')) { + text.push('\n'); + } + text.push_str(prefix); + text.push_str(suffix); app.inputs.file_editor.update(cx, |s, cx| { s.set_value(text, window, cx); }); diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index 14fb66d3..3b9e359e 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -146,7 +146,11 @@ fn file_row( } }), ) - .child(Icon::new(IconName::File).w(px(13.))) + .child( + Icon::new(crate::ui::file_icons::file_icon(path, false, false)) + .w(px(13.)) + .text_color(gpui::rgb(crate::ui::file_icons::file_icon_color(path, false))), + ) .child(div().flex_1().text_sm().child(name)) .child( div() diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs index 205c8acf..8b1adbec 100644 --- a/src-gpui/src/ui/mod.rs +++ b/src-gpui/src/ui/mod.rs @@ -16,6 +16,7 @@ impl gpui::Render for DragGhost { pub mod debug; pub mod dialogs; +pub mod file_icons; pub mod file_tree; pub mod file_viewer; pub mod git; diff --git a/src-gpui/src/ui/palette.rs b/src-gpui/src/ui/palette.rs index 36b0e9f8..4d73353a 100644 --- a/src-gpui/src/ui/palette.rs +++ b/src-gpui/src/ui/palette.rs @@ -1,6 +1,6 @@ -use gpui::{div, prelude::*, px, Context, Window}; +use gpui::{div, prelude::*, px, Context, KeyDownEvent, Window}; use gpui_component::input::Input; -use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon}; use crate::app::AppView; @@ -44,6 +44,21 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .border_color(theme.border) .shadow_lg() .on_click(|_, _, _| {}) + .on_key_down({ + let view = view.clone(); + move |ev: &KeyDownEvent, window, cx| { + view.update(cx, |app, cx| { + if app.handle_overlay_key( + ev.keystroke.key.as_str(), + ev.keystroke.modifiers.shift, + window, + cx, + ) { + cx.notify(); + } + }); + } + }) .child( div() .px_4() @@ -91,7 +106,11 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }); } }) - .child(Icon::new(IconName::File).w(px(16.))) + .child( + Icon::new(crate::ui::file_icons::file_icon(&r.path, false, false)) + .w(px(16.)) + .text_color(gpui::rgb(crate::ui::file_icons::file_icon_color(&r.path, false))), + ) .child(div().text_sm().child(r.name.clone())) .child( div() diff --git a/src-gpui/src/ui/shell.rs b/src-gpui/src/ui/shell.rs index 0597c22d..d2a3659b 100644 --- a/src-gpui/src/ui/shell.rs +++ b/src-gpui/src/ui/shell.rs @@ -36,13 +36,17 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }) .on_key_down({ let view = view.clone(); - move |ev: &KeyDownEvent, _, cx| { + move |ev: &KeyDownEvent, window, cx| { view.update(cx, |app, cx| { if let Some(profile) = app.data.overlay.sidebar_resize_focus { if app.nudge_sidebar(profile, ev.keystroke.key.as_str()) { cx.notify(); + return; } } + if app.handle_overlay_key(ev.keystroke.key.as_str(), ev.keystroke.modifiers.shift, window, cx) { + cx.notify(); + } }); } }) diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index 095d18a4..1f743779 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -5,7 +5,7 @@ use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::backend; -use crate::state::{AgentStatus, ContextMenu, DialogKind, Route}; +use crate::state::{AgentStatus, ContextMenu, DialogKind, Route, SidebarNavItem}; use crate::ui::settings; #[derive(Clone)] @@ -37,6 +37,17 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .bg(theme.sidebar) .border_r_1() .border_color(theme.border) + .tab_index(0) + .on_key_down({ + let view = view.clone(); + move |ev: &KeyDownEvent, _, cx| { + view.update(cx, |app, cx| { + if app.handle_sidebar_key(ev.keystroke.key.as_str()) { + cx.notify(); + } + }); + } + }) .child( h_flex() .id("sidebar-header") @@ -78,7 +89,8 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) "home-row", IconName::Inbox, app.t("home"), - app.data.route == Route::Home, + app.data.route == Route::Home + || app.data.overlay.sidebar_nav == Some(SidebarNavItem::Home), { let view = view.clone(); move |cx| { @@ -431,7 +443,26 @@ fn project_sections(app: &mut AppView, _window: &mut Window, cx: &mut Context = project.profiles.iter().filter(|p| !p.is_default).cloned().collect(); let expanded = app.data.overlay.expanded_projects.contains(&project.id) || extras.is_empty() || selected; let agent = app.agent_for_project(&project.id); @@ -481,6 +513,7 @@ fn project_row( .rounded_md() .gap_2() .when(selected, |el| el.bg(theme.sidebar_accent)) + .when(nav_focus && !selected, |el| el.border_1().border_color(theme.border)) .hover(|el| el.bg(theme.sidebar_accent)) .on_click({ let view = view.clone(); @@ -491,6 +524,7 @@ fn project_row( if app.data.overlay.sort_mode { return; } + app.data.overlay.sidebar_nav = Some(SidebarNavItem::Project(id.clone())); if let Some(pid) = default_profile.clone() { app.open_profile(&id, &pid); } @@ -683,6 +717,11 @@ fn project_row( let pid = profile.id.clone(); let proj = project.id.clone(); let selected = app.data.current_profile.as_deref() == Some(profile.id.as_str()); + let nav_focus = app.data.overlay.sidebar_nav.as_ref() + == Some(&SidebarNavItem::Profile { + project_id: project.id.clone(), + profile_id: profile.id.clone(), + }); let label = if profile.is_default { app.t("defaultProfile") } else { @@ -695,6 +734,7 @@ fn project_row( .rounded_md() .gap_2() .when(selected, |el| el.bg(theme.sidebar_accent)) + .when(nav_focus && !selected, |el| el.border_1().border_color(theme.border)) .hover(|el| el.bg(theme.sidebar_accent)) .on_click({ let view = view.clone(); @@ -702,6 +742,10 @@ fn project_row( let proj = proj.clone(); move |_, _, cx| { view.update(cx, |app, cx| { + app.data.overlay.sidebar_nav = Some(SidebarNavItem::Profile { + project_id: proj.clone(), + profile_id: pid.clone(), + }); app.open_profile(&proj, &pid); cx.notify(); }); diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index de04c843..9946c08b 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -6,19 +6,10 @@ use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; use crate::app::AppView; use crate::prefs::TermTheme; -pub fn render( - app: &mut AppView, - index: usize, - _window: &mut Window, - cx: &mut Context, -) -> impl IntoElement { +pub fn render(app: &mut AppView, index: usize, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = app.current_term_theme(); let view = cx.entity(); - let Some(term) = app - .data - .current_ws() - .and_then(|w| w.terminals.get(index)) - else { + let Some(term) = app.data.current_ws().and_then(|w| w.terminals.get(index)) else { return div().id("term-missing").into_any_element(); }; let search_open = term.search_open; @@ -61,20 +52,33 @@ pub fn render( .overflow_hidden() .on_key_down({ let view = view.clone(); - move |ev: &KeyDownEvent, _, cx| { + move |ev: &KeyDownEvent, window, cx| { if ev.keystroke.modifiers.control && ev.keystroke.key == "f" || ev.keystroke.modifiers.platform && ev.keystroke.key == "f" { view.update(cx, |app, cx| { - if let Some(term) = - app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) - { + if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { term.search_open = true; } cx.notify(); }); return; } + let handled = view.update(cx, |app, cx| { + let handled = app.handle_overlay_key( + ev.keystroke.key.as_str(), + ev.keystroke.modifiers.shift, + window, + cx, + ); + if handled { + cx.notify(); + } + handled + }); + if handled { + return; + } if let Some(bytes) = key_to_bytes(ev) { view.update(cx, |app, _| { app.write_to_active_pty(&bytes); @@ -152,10 +156,8 @@ pub fn render( let view = view.clone(); move |_, _, cx| { view.update(cx, |app, cx| { - if let Some(term) = app - .data - .current_ws_mut() - .and_then(|w| w.active_terminal_mut()) + if let Some(term) = + app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { term.search_open = false; } @@ -198,12 +200,7 @@ pub fn render( .into_any_element() } -fn render_grid( - term: &crate::state::TermSession, - theme: &TermTheme, - query: &str, - hit_ix: usize, -) -> impl IntoElement { +fn render_grid(term: &crate::state::TermSession, theme: &TermTheme, query: &str, hit_ix: usize) -> impl IntoElement { let screen = term.parser.screen(); let (rows, cols) = screen.size(); let hits = term.search_hits(query); @@ -225,12 +222,7 @@ fn render_grid( }) .unwrap_or_else(|| " ".into()); let (mut fg, mut bg) = cell - .map(|c| { - ( - map_color(c.fgcolor(), theme.fg), - map_color(c.bgcolor(), theme.bg), - ) - }) + .map(|c| (map_color(c.fgcolor(), theme.fg), map_color(c.bgcolor(), theme.bg))) .unwrap_or((theme.fg, theme.bg)); if cell.map(|c| c.inverse()).unwrap_or(false) { std::mem::swap(&mut fg, &mut bg); @@ -238,14 +230,13 @@ fn render_grid( if cell.map(|c| c.bold()).unwrap_or(false) { fg = brighten(fg); } - let highlight = hits.iter().any(|&(r, c, len)| { - r == row && (col as usize) >= c && (col as usize) < c + len - }); + let highlight = hits + .iter() + .any(|&(r, c, len)| r == row && (col as usize) >= c && (col as usize) < c + len); if highlight { bg = if active == Some((row, col as usize, query_len)) - || active.is_some_and(|(r, c, len)| { - r == row && (col as usize) >= c && (col as usize) < c + len - }) { + || active.is_some_and(|(r, c, len)| r == row && (col as usize) >= c && (col as usize) < c + len) + { 0xe3b341 } else { 0x3b6ea8 @@ -260,13 +251,11 @@ fn render_grid( } spans.push((ch, fg, bg, highlight)); } - h_flex().children(spans.into_iter().map(|(text, fg, bg, _)| { - div() - .bg(rgb(bg)) - .text_color(rgb(fg)) - .whitespace_nowrap() - .child(text) - })) + h_flex().children( + spans + .into_iter() + .map(|(text, fg, bg, _)| div().bg(rgb(bg)).text_color(rgb(fg)).whitespace_nowrap().child(text)), + ) })) } @@ -280,8 +269,8 @@ fn map_color(color: vt100::Color, fallback: u32) -> u32 { fn ansi_color(idx: u8) -> u32 { const BASIC: [u32; 16] = [ - 0x0d1117, 0xff7b72, 0x3fb950, 0xd29922, 0x58a6ff, 0xbc8cff, 0x39c5cf, 0xc9d1d9, 0x6e7681, - 0xffa198, 0x56d364, 0xe3b341, 0x79c0ff, 0xd2a8ff, 0x56d4dd, 0xffffff, + 0x0d1117, 0xff7b72, 0x3fb950, 0xd29922, 0x58a6ff, 0xbc8cff, 0x39c5cf, 0xc9d1d9, 0x6e7681, 0xffa198, 0x56d364, + 0xe3b341, 0x79c0ff, 0xd2a8ff, 0x56d4dd, 0xffffff, ]; if (idx as usize) < 16 { return BASIC[idx as usize]; diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 8f258542..33e0653a 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -519,7 +519,7 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { let profile = ws.profile_id.clone(); Tab::new() .label(file.title.clone()) - .icon(IconName::File) + .icon(crate::ui::file_icons::file_icon(&file.path, false, false)) .selected(selected) .suffix( h_flex() diff --git a/start.sh b/start.sh index 0d6231d9..72650906 100755 --- a/start.sh +++ b/start.sh @@ -4,4 +4,10 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "${SCRIPT_DIR}" -nr start +# Primary product entry: native GPUI shell. +if command -v just >/dev/null 2>&1; then + exec just gpui +fi + +cd src-gpui +exec cargo run From dae6fc37f4df4409975abc3d1c46c8e54e839e08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 06:10:38 +0000 Subject: [PATCH 09/50] Port remaining GPUI workspace surfaces from the inventory. Add project-level terminal templates, markdown notes with live preview, git-dirty files in the tree, unique New File/Folder names, completed agent-dot dismiss, drop-on-terminal-tab paths, file find, archive counts, top-bar reorder, and Enter-to-submit on create/rename dialogs. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 163 ++++++++++++++++++- src-gpui/src/ui/dialogs.rs | 286 +++++++++++++++++++++++---------- src-gpui/src/ui/file_tree.rs | 6 +- src-gpui/src/ui/file_viewer.rs | 32 +++- src-gpui/src/ui/mod.rs | 5 + src-gpui/src/ui/notes.rs | 86 ++++++++-- src-gpui/src/ui/settings.rs | 49 +++++- src-gpui/src/ui/sidebar.rs | 4 + src-gpui/src/ui/workspace.rs | 45 +++++- 9 files changed, 556 insertions(+), 120 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 55f141e9..bb5da02c 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -124,7 +124,7 @@ impl AppView { notes: input(window, cx, "", true), palette: input(window, cx, "", false), file_editor: input(window, cx, "", true), - file_search: input(window, cx, "", false), + file_search: input(window, cx, "Find", false), debug_search: input(window, cx, "", false), branch_search: input(window, cx, "", false), new_path: input(window, cx, "", false), @@ -355,6 +355,7 @@ impl AppView { } } } + self.inject_git_paths(profile_id); } pub fn load_tree_root(&mut self, profile_id: &str) { @@ -412,6 +413,46 @@ impl AppView { } Err(err) => ws.tree_error = Some(err.to_string()), } + self.inject_git_paths(profile_id); + } + + fn inject_git_paths(&mut self, profile_id: &str) { + let Some(ws) = self.data.workspaces.get_mut(profile_id) else { + return; + }; + if !ws.tree.contains_key("") { + return; + } + let files: Vec = ws.git_files.iter().map(|(p, _)| p.clone()).collect(); + for path in files { + if path.is_empty() || ws.tree.contains_key(&path) { + continue; + } + let mut parent = String::new(); + let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); + for (i, part) in parts.iter().enumerate() { + let current = if parent.is_empty() { + (*part).to_string() + } else { + format!("{parent}/{part}") + }; + let is_last = i + 1 == parts.len(); + ws.tree.entry(current.clone()).or_insert_with(|| TreeNode { + path: current.clone(), + name: (*part).to_string(), + is_dir: !is_last, + expanded: false, + children_loaded: is_last, + children: Vec::new(), + }); + if let Some(pnode) = ws.tree.get_mut(&parent) { + if !pnode.children.contains(¤t) { + pnode.children.push(current.clone()); + } + } + parent = current; + } + } } pub fn toggle_dir(&mut self, profile_id: &str, path: &str) { @@ -613,6 +654,15 @@ impl AppView { } } + pub fn write_path_to_pty(&mut self, session_id: &str, path: &str) { + let quoted = if path.contains(' ') { + format!("\"{path}\" ") + } else { + format!("{path} ") + }; + let _ = self.backend.write_pty(session_id, quoted.as_bytes()); + } + pub fn write_to_active_pty(&mut self, bytes: &[u8]) { if let Some(ws) = self.data.current_ws() { if let Some(UnifiedTab::Terminal { index }) = ws.active { @@ -1318,6 +1368,46 @@ impl AppView { } } + pub fn add_project_template(&mut self, cx: &mut Context) { + let name = self.inputs.template_name.read(cx).value().to_string(); + if name.trim().is_empty() { + return; + } + let Some(project_id) = self + .data + .overlay + .dialog_project + .clone() + .or_else(|| self.data.current_project.clone()) + else { + return; + }; + let template = model::project::ProjectTerminalTemplate { + id: uuid::Uuid::new_v4().to_string(), + name, + cwd: self.inputs.template_cwd.read(cx).value().to_string(), + commands: lines(&self.inputs.template_commands.read(cx).value()), + }; + if let Some(ws) = self.data.workspaces.values_mut().find(|w| w.project_id == project_id) { + ws.config.terminal_templates.push(template); + } + } + + pub fn remove_project_template(&mut self, id: &str) { + let Some(project_id) = self + .data + .overlay + .dialog_project + .clone() + .or_else(|| self.data.current_project.clone()) + else { + return; + }; + if let Some(ws) = self.data.workspaces.values_mut().find(|w| w.project_id == project_id) { + ws.config.terminal_templates.retain(|t| t.id != id); + } + } + pub fn save_project_settings(&mut self, cx: &mut Context) { let Some(project_id) = self .data @@ -1338,6 +1428,14 @@ impl AppView { config.init_script = lines(&self.inputs.init_script.read(cx).value()); config.setup_script = lines(&self.inputs.setup_script.read(cx).value()); config.teardown_script = lines(&self.inputs.teardown_script.read(cx).value()); + let templates = self + .data + .workspaces + .values() + .find(|w| w.project_id == project_id) + .map(|w| w.config.terminal_templates.clone()) + .unwrap_or_else(|| config.terminal_templates.clone()); + config.terminal_templates = templates; if let Err(err) = self.backend.save_project_config(&project_id, &config) { self.data .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); @@ -1460,12 +1558,24 @@ impl AppView { return; }; let name = self.inputs.new_path.read(cx).value().to_string(); + let existing: Vec = self + .data + .workspaces + .get(&profile_id) + .map(|w| { + w.tree + .get("") + .map(|root| { + root.children + .iter() + .filter_map(|p| w.tree.get(p).map(|n| n.name.clone())) + .collect() + }) + .unwrap_or_default() + }) + .unwrap_or_default(); let name = if name.trim().is_empty() { - if is_dir { - "New Folder".into() - } else { - "New File".into() - } + unique_tree_name(&existing, if is_dir { "New Folder" } else { "New File" }) } else { name }; @@ -1721,6 +1831,18 @@ impl AppView { } pub fn handle_overlay_key(&mut self, key: &str, shift: bool, window: &mut Window, cx: &mut Context) -> bool { + if self.data.overlay.dialog == Some(DialogKind::CreateProject) && key == "enter" { + self.create_project_from_dialog(window, cx); + return true; + } + if self.data.overlay.dialog == Some(DialogKind::CreateProfile) && key == "enter" { + self.create_profile_from_dialog(cx); + return true; + } + if self.data.overlay.dialog == Some(DialogKind::RenameProject) && key == "enter" { + self.rename_dialog_project(cx); + return true; + } if self.data.overlay.palette_open { return match key { "up" => { @@ -1778,6 +1900,20 @@ impl AppView { } } +pub fn unique_tree_name(existing: &[String], base: &str) -> String { + if !existing.iter().any(|n| n == base) { + return base.to_string(); + } + let mut n = 2; + loop { + let candidate = format!("{base} {n}"); + if !existing.iter().any(|name| name == &candidate) { + return candidate; + } + n += 1; + } +} + fn agent_rank(status: AgentStatus) -> u8 { match status { AgentStatus::Running => 3, @@ -1969,3 +2105,18 @@ impl gpui::Render for AppView { pub fn input_el(state: &Entity) -> Input { Input::new(state) } + +#[cfg(test)] +mod tests { + use super::unique_tree_name; + + #[test] + fn unique_tree_name_adds_numbers() { + assert_eq!(unique_tree_name(&[], "New File"), "New File"); + assert_eq!(unique_tree_name(&["New File".into()], "New File"), "New File 2"); + assert_eq!( + unique_tree_name(&["New File".into(), "New File 2".into()], "New File"), + "New File 3" + ); + } +} diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index 819111ee..e7783e1a 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -1,8 +1,8 @@ use gpui::{div, prelude::*, px, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{Disableable, Selectable}; use gpui_component::input::Input; use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::backend; @@ -60,11 +60,16 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> .child( v_flex() .id("dialog-panel") - .w(px(if matches!(kind, DialogKind::ProjectSettings | DialogKind::SwitchBranch | DialogKind::DebugLog) { - 560. - } else { - 380. - })) + .w(px( + if matches!( + kind, + DialogKind::ProjectSettings | DialogKind::SwitchBranch | DialogKind::DebugLog + ) { + 560. + } else { + 380. + }, + )) .max_h(px(640.)) .p_4() .gap_3() @@ -78,21 +83,15 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> h_flex() .justify_between() .child(div().font_semibold().child(title)) - .child( - Button::new("dlg-x") - .ghost() - .xsmall() - .icon(IconName::Close) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.dialog = None; - cx.notify(); - }); - } - }), - ), + .child(Button::new("dlg-x").ghost().xsmall().icon(IconName::Close).on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + cx.notify(); + }); + } + })), ) .child(dialog_body(app, kind, window, cx)) .child(dialog_footer(app, kind, cx)), @@ -127,22 +126,17 @@ fn dialog_body( h_flex() .justify_between() .child(div().text_sm().child(app.t("folder"))) - .child( - Button::new("rechoose") - .xsmall() - .label(app.t("chooseFolder")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - if let Some(p) = backend::pick_folder() { - app.data.overlay.dialog_folder = Some(p); - } - cx.notify(); - }); + .child(Button::new("rechoose").xsmall().label(app.t("chooseFolder")).on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(p) = backend::pick_folder() { + app.data.overlay.dialog_folder = Some(p); } - }), - ), + cx.notify(); + }); + } + })), ) .child( div() @@ -188,10 +182,7 @@ fn dialog_body( .child(div().text_xs().text_color(theme.muted_foreground).child(hint)) .into_any_element() } - DialogKind::DeleteProject => div() - .text_sm() - .child(app.t("confirmDeleteProject")) - .into_any_element(), + DialogKind::DeleteProject => div().text_sm().child(app.t("confirmDeleteProject")).into_any_element(), DialogKind::RenameProject => v_flex() .gap_2() .child(div().text_sm().child(app.t("newName"))) @@ -239,7 +230,12 @@ fn dialog_body( v_flex() .gap_2() .child(div().text_sm().child(app.t("projectWorktreeDir"))) - .child(div().text_xs().text_color(theme.muted_foreground).child(app.t("projectWorktreeDirDesc"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("projectWorktreeDirDesc")), + ) .child(Input::new(&app.inputs.worktree)) .child(div().text_sm().child(app.t("initScript"))) .child(Input::new(&app.inputs.init_script)) @@ -249,10 +245,85 @@ fn dialog_body( .child(Input::new(&app.inputs.teardown_script)) .into_any_element() } else { + let templates = app + .data + .workspaces + .values() + .find(|w| { + Some(w.project_id.as_str()) == app.data.overlay.dialog_project.as_deref() + || Some(w.project_id.as_str()) == app.data.current_project.as_deref() + }) + .map(|w| w.config.terminal_templates.clone()) + .unwrap_or_default(); v_flex() .gap_2() .child(div().text_sm().child(app.t("projectTerminalTemplates"))) - .child(div().text_xs().text_color(theme.muted_foreground).child(app.t("projectTerminalTemplatesDescription"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("projectTerminalTemplatesDescription")), + ) + .child(if templates.is_empty() { + div() + .p_2() + .text_sm() + .child(app.t("noTerminalTemplates")) + .into_any_element() + } else { + v_flex() + .gap_1() + .children(templates.into_iter().map(|t| { + let id = t.id.clone(); + h_flex() + .id(crate::ui::eid(format!("ptpl-{id}"))) + .justify_between() + .child( + v_flex() + .child(div().font_medium().text_sm().child(t.name.clone())) + .child( + div().text_xs().text_color(theme.muted_foreground).child(t.cwd.clone()), + ), + ) + .child( + Button::new(crate::ui::eid(format!("ptpl-del-{id}"))) + .danger() + .xsmall() + .label(app.t("delete")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.remove_project_template(&id); + cx.notify(); + }); + } + }), + ) + })) + .into_any_element() + }) + .child(div().text_xs().child(app.t("terminalTemplateName"))) + .child(Input::new(&app.inputs.template_name)) + .child(div().text_xs().child(app.t("terminalTemplateCwd"))) + .child(Input::new(&app.inputs.template_cwd)) + .child(div().text_xs().child(app.t("terminalTemplateCommands"))) + .child(Input::new(&app.inputs.template_commands)) + .child( + Button::new("add-project-tpl") + .xsmall() + .primary() + .label(app.t("addTerminalTemplate")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.add_project_template(cx); + cx.notify(); + }); + } + }), + ) .into_any_element() }) .into_any_element(), @@ -278,11 +349,14 @@ fn dialog_body( ) }) .into_any_element(), - DialogKind::CloseUnsaved => div().text_sm().child(crate::i18n::tf( - app.data.locale, - "closeUnsavedFileDescription", - &[("file", app.data.overlay.dialog_file.as_deref().unwrap_or(""))], - )).into_any_element(), + DialogKind::CloseUnsaved => div() + .text_sm() + .child(crate::i18n::tf( + app.data.locale, + "closeUnsavedFileDescription", + &[("file", app.data.overlay.dialog_file.as_deref().unwrap_or(""))], + )) + .into_any_element(), DialogKind::SwitchBranch => { let q = app.inputs.branch_search.read(cx).value().to_string(); let branches: Vec<_> = app @@ -326,11 +400,21 @@ fn dialog_body( } }) .child(div().flex_1().child(name)) - .when(b.is_current, |el| el.child(badge(&app.t("branchCurrentLabel"), theme.muted_foreground))) - .when(b.is_trunk, |el| el.child(badge(&app.t("branchTrunkLabel"), theme.muted_foreground))) - .when(b.is_used, |el| el.child(badge(&app.t("branchUsedLabel"), theme.warning))) - .when(b.ahead > 0, |el| el.child(div().text_xs().child(format!("↑{}", b.ahead)))) - .when(b.behind > 0, |el| el.child(div().text_xs().child(format!("↓{}", b.behind)))) + .when(b.is_current, |el| { + el.child(badge(&app.t("branchCurrentLabel"), theme.muted_foreground)) + }) + .when(b.is_trunk, |el| { + el.child(badge(&app.t("branchTrunkLabel"), theme.muted_foreground)) + }) + .when(b.is_used, |el| { + el.child(badge(&app.t("branchUsedLabel"), theme.warning)) + }) + .when(b.ahead > 0, |el| { + el.child(div().text_xs().child(format!("↑{}", b.ahead))) + }) + .when(b.behind > 0, |el| { + el.child(div().text_xs().child(format!("↓{}", b.behind))) + }) })) .into_any_element() }) @@ -339,7 +423,12 @@ fn dialog_body( DialogKind::OpenLink => v_flex() .gap_2() .child(div().text_sm().child(app.t("terminalOpenLinkConfirmDescription"))) - .child(div().font_family("monospace").text_xs().child(app.data.overlay.dialog_url.clone().unwrap_or_default())) + .child( + div() + .font_family("monospace") + .text_xs() + .child(app.data.overlay.dialog_url.clone().unwrap_or_default()), + ) .into_any_element(), DialogKind::ChooseFile => v_flex() .gap_2() @@ -390,12 +479,7 @@ fn dialog_body( } fn badge(text: &str, color: gpui::Hsla) -> impl IntoElement { - div() - .px_1() - .text_xs() - .rounded_md() - .bg(color) - .child(text.to_string()) + div().px_1().text_xs().rounded_md().bg(color).child(text.to_string()) } fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> impl IntoElement { @@ -423,25 +507,23 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> DialogKind::ReviewQueue => app.t("reviewCommentsCopied"), _ => app.t("cancel"), }; - let show_ok = !matches!(kind, DialogKind::SwitchBranch | DialogKind::DebugLog | DialogKind::ChooseFile); + let show_ok = !matches!( + kind, + DialogKind::SwitchBranch | DialogKind::DebugLog | DialogKind::ChooseFile + ); h_flex() .justify_end() .gap_2() - .child( - Button::new("dlg-cancel") - .small() - .label(app.t("cancel")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.dialog = None; - cx.notify(); - }); - } - }), - ) + .child(Button::new("dlg-cancel").small().label(app.t("cancel")).on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + cx.notify(); + }); + } + })) .when(show_ok, |el| { el.child( Button::new("dlg-ok") @@ -584,7 +666,9 @@ enum MenuAction { CopyAbs, DeletePath, Template(usize), + ProjectTemplate(usize), NewTerm, + Header, } fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec<(String, bool, MenuAction)> { @@ -600,8 +684,16 @@ fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec<(String, bool, MenuActio ContextMenu::Profile { .. } => vec![(app.t("deleteProfile"), true, MenuAction::DeleteProfile)], ContextMenu::File { .. } => vec![ (app.t("fileTreeContextMenuOpen"), false, MenuAction::Open), - (app.t("fileTreeContextMenuOpenInDefaultApp"), false, MenuAction::OpenDefault), - (app.t("fileTreeContextMenuRevealInFileManager"), false, MenuAction::Reveal), + ( + app.t("fileTreeContextMenuOpenInDefaultApp"), + false, + MenuAction::OpenDefault, + ), + ( + app.t("fileTreeContextMenuRevealInFileManager"), + false, + MenuAction::Reveal, + ), (app.t("fileTreeContextMenuRefresh"), false, MenuAction::Refresh), (app.t("fileTreeContextMenuNewFile"), false, MenuAction::NewFile), (app.t("fileTreeContextMenuNewFolder"), false, MenuAction::NewFolder), @@ -614,25 +706,37 @@ fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec<(String, bool, MenuActio (app.t("fileTreeContextMenuNewFile"), false, MenuAction::NewFile), (app.t("fileTreeContextMenuNewFolder"), false, MenuAction::NewFolder), (app.t("fileTreeContextMenuRefresh"), false, MenuAction::Refresh), - (app.t("fileTreeContextMenuRevealInFileManager"), false, MenuAction::Reveal), + ( + app.t("fileTreeContextMenuRevealInFileManager"), + false, + MenuAction::Reveal, + ), ], ContextMenu::NewTerminal => { let mut items = vec![(app.t("newTerminal"), false, MenuAction::NewTerm)]; - for (i, t) in app.data.prefs.templates.iter().enumerate() { - items.push((t.name.clone(), false, MenuAction::Template(i))); + let project = app + .data + .current_ws() + .map(|w| w.config.terminal_templates.clone()) + .unwrap_or_default(); + if !project.is_empty() { + items.push((app.t("projectTerminalTemplates"), false, MenuAction::Header)); + for (i, t) in project.iter().enumerate() { + items.push((t.name.clone(), false, MenuAction::ProjectTemplate(i))); + } + } + if !app.data.prefs.templates.is_empty() { + items.push((app.t("globalTerminalTemplates"), false, MenuAction::Header)); + for (i, t) in app.data.prefs.templates.iter().enumerate() { + items.push((t.name.clone(), false, MenuAction::Template(i))); + } } items } } } -fn run_menu( - app: &mut AppView, - menu: &ContextMenu, - action: MenuAction, - window: &mut Window, - cx: &mut Context, -) { +fn run_menu(app: &mut AppView, menu: &ContextMenu, action: MenuAction, window: &mut Window, cx: &mut Context) { match (menu, action) { (ContextMenu::Project { id }, MenuAction::DeleteProject) => { app.data.overlay.dialog = Some(DialogKind::DeleteProject); @@ -698,6 +802,16 @@ fn run_menu( app.create_terminal(&t.name, &t.cwd, t.commands); } } + (_, MenuAction::Header) => {} + (_, MenuAction::ProjectTemplate(i)) => { + if let Some(t) = app + .data + .current_ws() + .and_then(|w| w.config.terminal_templates.get(i).cloned()) + { + app.create_terminal(&t.name, &t.cwd, t.commands); + } + } _ => {} } } diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index 00d6bd5d..816ef664 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -5,11 +5,7 @@ use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, Style use crate::app::AppView; use crate::state::ContextMenu; - -#[derive(Clone)] -struct TreeDrag { - path: String, -} +use crate::ui::TreeDrag; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 39a5e3cc..313a62c8 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -86,17 +86,32 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .into_any_element(); } + let q = app.inputs.file_search.read(cx).value().to_string(); + let hits = if q.is_empty() { + 0 + } else { + file.draft.matches(&q).count() + }; v_flex() .id("text-viewer") .size_full() + .font_family(app.data.prefs.font_family.clone()) + .text_size(px(app.data.prefs.font_size)) .child( h_flex() .w_full() .px_2() .py_1() - .justify_end() + .gap_2() .border_b_1() .border_color(theme.border) + .child(div().flex_1().child(Input::new(&app.inputs.file_search))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(if q.is_empty() { String::new() } else { format!("{hits}") }), + ) .child( Button::new("file-save") .xsmall() @@ -135,7 +150,11 @@ fn preview_pane(_app: &AppView, file: &crate::state::OpenFileTab, cx: &mut Conte .child( h_flex() .gap_2() - .child(div().text_xs().child(file.preview_kind.clone())) + .child(div().text_xs().child(if file.preview_kind == "office" { + "Office Preview".to_string() + } else { + "Preview".to_string() + })) .child(Button::new("open-external").xsmall().label("Open").on_click({ let view = view.clone(); let path = path.clone(); @@ -172,12 +191,19 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> .into_any_element(); } if file.preview_kind == "archive" && !file.archive_entries.is_empty() { + let files = file.archive_entries.iter().filter(|(_, k)| k != "dir").count(); + let folders = file.archive_entries.iter().filter(|(_, k)| k == "dir").count(); return v_flex() .id("archive-preview") .size_full() .p_3() .gap_1() - .overflow_y_scroll() + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(format!("{files} files / {folders} folders")), + ) .children(file.archive_entries.iter().map(|(path, kind)| { h_flex() .gap_2() diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs index 8b1adbec..73637a08 100644 --- a/src-gpui/src/ui/mod.rs +++ b/src-gpui/src/ui/mod.rs @@ -8,6 +8,11 @@ pub struct DragGhost { pub label: String, } +#[derive(Clone)] +pub struct TreeDrag { + pub path: String, +} + impl gpui::Render for DragGhost { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().px_2().py_1().rounded_md().opacity(0.45).child(self.label.clone()) diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs index 3b7cdb08..85bfba2b 100644 --- a/src-gpui/src/ui/notes.rs +++ b/src-gpui/src/ui/notes.rs @@ -1,22 +1,22 @@ -use gpui::{div, prelude::*, Context, Window}; +use gpui::{div, prelude::*, px, Context, Window}; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; -use gpui_component::{h_flex, v_flex, ActiveTheme, StyledExt}; +use gpui_component::text::TextView; +use gpui_component::{h_flex, v_flex, ActiveTheme, Sizable, StyledExt}; use crate::app::AppView; use crate::state::NotesStatus; -pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { +pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); - let status = app - .data - .current_ws() - .map(|w| w.notes_status) - .unwrap_or_default(); + let view = cx.entity(); + let status = app.data.current_ws().map(|w| w.notes_status).unwrap_or_default(); let badge = match status { NotesStatus::Saving => app.t("notesSaving"), NotesStatus::Failed => app.t("notesSaveFailedShort"), NotesStatus::Saved => app.t("notesSaved"), }; + let draft = app.inputs.notes.read(cx).value().to_string(); v_flex() .id("notes-panel") @@ -30,19 +30,71 @@ pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context .border_b_1() .border_color(theme.border) .child(div().text_xs().font_semibold().child(app.t("notes"))) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(badge), - ), + .child(div().text_xs().text_color(theme.muted_foreground).child(badge)), + ) + .child( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_1() + .border_b_1() + .border_color(theme.border) + .child(note_md("n-h1", "H1", "# ", "", &view)) + .child(note_md("n-b", "B", "**", "**", &view)) + .child(note_md("n-i", "I", "*", "*", &view)) + .child(note_md("n-code", "`", "`", "`", &view)) + .child(note_md("n-ul", "•", "- ", "", &view)) + .child(note_md("n-q", ">", "> ", "", &view)) + .child(note_md("n-link", "[]", "[", "](url)", &view)), ) .child( - div() + v_flex() .id("notes-editor") .flex_1() .min_h_0() - .p_2() - .child(Input::new(&app.inputs.notes)), + .child( + div() + .flex_1() + .min_h(px(120.)) + .p_2() + .child(Input::new(&app.inputs.notes)), + ) + .child( + div() + .flex_1() + .min_h(px(80.)) + .p_2() + .border_t_1() + .border_color(theme.border) + .child(TextView::markdown("notes-preview", draft, window, cx)), + ), ) } + +fn note_md( + id: &'static str, + label: &'static str, + prefix: &'static str, + suffix: &'static str, + view: &gpui::Entity, +) -> impl IntoElement { + let view = view.clone(); + Button::new(id) + .ghost() + .xsmall() + .label(label) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + let mut text = app.inputs.notes.read(cx).value().to_string(); + if !text.is_empty() && !text.ends_with('\n') && prefix.ends_with(' ') { + text.push('\n'); + } + text.push_str(prefix); + text.push_str(suffix); + app.inputs.notes.update(cx, |s, cx| { + s.set_value(text, window, cx); + }); + }); + }) +} diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 0bba8bcf..23a10fef 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -637,8 +637,53 @@ impl SettingsView { .gap_4() .child(div().font_semibold().child(self.t("topbarPreview"))) .child( - h_flex().gap_2().children(self.prefs.topbar_controls.iter().cloned().map(|id| { - div().px_2().py_1().rounded_md().border_1().border_color(cx.theme().border).child(id) + v_flex().gap_1().children(self.prefs.topbar_controls.iter().cloned().enumerate().map(|(ix, id)| { + let view = view.clone(); + h_flex() + .id(crate::ui::eid(format!("tb-order-{id}"))) + .gap_2() + .px_2() + .py_1() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .child(div().flex_1().child(id.clone())) + .child( + Button::new(crate::ui::eid(format!("tb-up-{id}"))) + .ghost() + .xsmall() + .icon(IconName::ChevronUp) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if ix > 0 { + this.prefs.topbar_controls.swap(ix, ix - 1); + this.persist(cx); + cx.notify(); + } + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("tb-dn-{id}"))) + .ghost() + .xsmall() + .icon(IconName::ChevronDown) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if ix + 1 < this.prefs.topbar_controls.len() { + this.prefs.topbar_controls.swap(ix, ix + 1); + this.persist(cx); + cx.notify(); + } + }); + } + }), + ) })), ) .child(div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDragHint"))) diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index 1f743779..069f4a85 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -833,6 +833,9 @@ fn nav_row( } pub fn agent_dot(status: AgentStatus) -> impl IntoElement { + if status == AgentStatus::Idle { + return div().id("agent-dot-idle").into_any_element(); + } let (color, pulse) = match status { AgentStatus::Waiting => (gpui::rgb(0xfacc15), false), AgentStatus::Running => (gpui::rgb(0x34d399), true), @@ -844,6 +847,7 @@ pub fn agent_dot(status: AgentStatus) -> impl IntoElement { .rounded_full() .bg(color) .when(pulse, |el| el.opacity(0.85)) + .into_any_element() } #[allow(dead_code)] diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 33e0653a..d560219e 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -474,6 +474,11 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { let selected = matches!(active, Some(UnifiedTab::Terminal { index }) if index == ix); let id = term.id.clone(); let profile = term.profile_id.clone(); + let show_completed = term.agent == crate::state::AgentStatus::Completed && !term.completed_hidden; + let show_live = matches!( + term.agent, + crate::state::AgentStatus::Running | crate::state::AgentStatus::Waiting + ); Tab::new() .label(term.title.clone()) .icon(IconName::SquareTerminal) @@ -482,7 +487,35 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { h_flex() .gap_1() .child(agent_kind_mark(term.agent_kind)) - .child(sidebar::agent_dot(term.agent)) + .when(show_live, |el| el.child(sidebar::agent_dot(term.agent))) + .when(show_completed, |el| { + let view = view.clone(); + let id = id.clone(); + el.child( + div() + .id(crate::ui::eid(format!("done-{id}"))) + .size(px(8.)) + .rounded_full() + .bg(gpui::rgb(0x22c55e)) + .cursor(gpui::CursorStyle::PointingHand) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(term) = app + .data + .current_ws_mut() + .and_then(|w| w.terminals.iter_mut().find(|t| t.id == id)) + { + term.completed_hidden = true; + } + cx.notify(); + }); + } + }), + ) + }) .child( Button::new(crate::ui::eid(format!("close-term-{id}"))) .ghost() @@ -512,6 +545,16 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { }); } }) + .on_drop({ + let view = view.clone(); + let id = id.clone(); + move |drag: &crate::ui::TreeDrag, _, cx| { + view.update(cx, |app, cx| { + app.write_path_to_pty(&id, &drag.path); + cx.notify(); + }); + } + }) })) .children(ws.files.iter().enumerate().map(|(ix, file)| { let selected = matches!(active, Some(UnifiedTab::File { index }) if index == ix); From 8669637adff72eb3d5fae6df25218de954dbeaab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 06:13:55 +0000 Subject: [PATCH 10/50] Add GPUI installed-app pickers, review copy, and markdown slash menu. Detect editors, terminals, browsers, and shells; expose them in Settings. Review Queue can copy or copy-and-clear. Terminal links skip confirm with Ctrl/Cmd-click and offer Open With. Markdown files get a / slash menu. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 46 +++++++- src-gpui/src/platform.rs | 185 ++++++++++++++++++++++++++++++--- src-gpui/src/ui/dialogs.rs | 55 ++++++++-- src-gpui/src/ui/file_viewer.rs | 66 ++++++++++++ src-gpui/src/ui/settings.rs | 94 +++++++++++++++++ src-gpui/src/ui/terminal.rs | 5 +- 6 files changed, 418 insertions(+), 33 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index bb5da02c..f561fa6f 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -1527,11 +1527,47 @@ impl AppView { self.write_to_active_pty(quoted.as_bytes()); } - pub fn open_clickable(&mut self, token: &crate::detector::Clickable, window: &mut Window, cx: &mut Context) { + pub fn copy_review_comments(&mut self, clear: bool, cx: &mut App) { + let text = self.data.overlay.review_comments.join("\n"); + cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); + if clear { + self.data.overlay.review_comments.clear(); + self.data + .push_toast(ToastKind::Success, self.t("reviewCommentsCopiedAndCleared"), ""); + } else { + self.data + .push_toast(ToastKind::Success, self.t("reviewCommentsCopied"), ""); + } + self.data.overlay.dialog = None; + } + + pub fn open_url_with(&mut self, browser: &str) { + let Some(url) = self.data.overlay.dialog_url.clone() else { + return; + }; + if browser.is_empty() { + let _ = open::that(&url); + } else { + let _ = std::process::Command::new(browser).arg(&url).spawn(); + } + self.data.overlay.dialog = None; + } + + pub fn open_clickable( + &mut self, + token: &crate::detector::Clickable, + skip_confirm: bool, + window: &mut Window, + cx: &mut Context, + ) { match token { crate::detector::Clickable::Url(url) => { - self.data.overlay.dialog = Some(DialogKind::OpenLink); + if skip_confirm { + let _ = open::that(url); + return; + } self.data.overlay.dialog_url = Some(url.clone()); + self.data.overlay.dialog = Some(DialogKind::OpenLink); } crate::detector::Clickable::Path(path) => { let Some(profile_id) = self.data.current_profile.clone() else { @@ -1635,7 +1671,7 @@ impl AppView { "cursor" => "cursor", "windsurf" => "windsurf", "zed" => "zed", - "sublime" => "subl", + "sublime" | "subl" => "subl", _ => "code", }, "terminal" => match self.data.prefs.terminal_app.as_str() { @@ -1647,6 +1683,10 @@ impl AppView { }, _ => return, }; + if kind == "terminal" && self.data.prefs.terminal_app == "iterm2" { + let _ = std::process::Command::new("open").args(["-a", "iTerm", folder]).spawn(); + return; + } let _ = std::process::Command::new(cmd).arg(folder).spawn(); } diff --git a/src-gpui/src/platform.rs b/src-gpui/src/platform.rs index 412f6a4c..3ea890a5 100644 --- a/src-gpui/src/platform.rs +++ b/src-gpui/src/platform.rs @@ -1,5 +1,138 @@ use std::sync::OnceLock; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DetectedApp { + pub id: &'static str, + pub command: &'static str, +} + +pub fn command_exists(command: &str) -> bool { + if command.is_empty() { + return false; + } + if let Some(paths) = std::env::var_os("PATH") { + if std::env::split_paths(&paths).any(|dir| dir.join(command).is_file()) { + return true; + } + } + #[cfg(target_os = "macos")] + { + let apps = [ + ("cursor", "/Applications/Cursor.app"), + ("code", "/Applications/Visual Studio Code.app"), + ("windsurf", "/Applications/Windsurf.app"), + ("zed", "/Applications/Zed.app"), + ("subl", "/Applications/Sublime Text.app"), + ("ghostty", "/Applications/Ghostty.app"), + ("kitty", "/Applications/kitty.app"), + ("warp", "/Applications/Warp.app"), + ("iterm2", "/Applications/iTerm.app"), + ("firefox", "/Applications/Firefox.app"), + ("chrome", "/Applications/Google Chrome.app"), + ("safari", "/Applications/Safari.app"), + ("github", "/Applications/GitHub Desktop.app"), + ]; + if let Some((_, path)) = apps.iter().find(|(id, _)| *id == command) { + return std::path::Path::new(path).exists(); + } + } + false +} + +pub fn installed_editors() -> Vec { + [ + DetectedApp { + id: "vscode", + command: "code", + }, + DetectedApp { + id: "cursor", + command: "cursor", + }, + DetectedApp { + id: "windsurf", + command: "windsurf", + }, + DetectedApp { + id: "zed", + command: "zed", + }, + DetectedApp { + id: "sublime", + command: "subl", + }, + ] + .into_iter() + .filter(|app| command_exists(app.command)) + .collect() +} + +pub fn installed_terminals() -> Vec { + [ + DetectedApp { + id: "ghostty", + command: "ghostty", + }, + DetectedApp { + id: "iterm2", + command: "iterm2", + }, + DetectedApp { + id: "kitty", + command: "kitty", + }, + DetectedApp { + id: "warp", + command: "warp", + }, + ] + .into_iter() + .filter(|app| command_exists(app.command)) + .collect() +} + +pub fn installed_browsers() -> Vec { + [ + DetectedApp { + id: "firefox", + command: "firefox", + }, + DetectedApp { + id: "chrome", + command: "google-chrome", + }, + DetectedApp { + id: "chromium", + command: "chromium", + }, + DetectedApp { + id: "safari", + command: "safari", + }, + ] + .into_iter() + .filter(|app| command_exists(app.command) || command_exists(app.id)) + .collect() +} + +pub fn list_shells() -> Vec { + let mut shells = Vec::new(); + if let Ok(text) = std::fs::read_to_string("/etc/shells") { + for line in text.lines() { + let line = line.trim(); + if line.starts_with('/') && std::path::Path::new(line).is_file() { + shells.push(line.to_string()); + } + } + } + for fallback in ["/bin/zsh", "/bin/bash", "/bin/sh", "/usr/bin/fish"] { + if std::path::Path::new(fallback).is_file() && !shells.iter().any(|s| s == fallback) { + shells.push(fallback.to_string()); + } + } + shells +} + #[derive(Clone, Debug)] pub struct SystemFont { pub family: String, @@ -18,10 +151,7 @@ pub fn list_mono_fonts() -> Vec { .map(|f| f.family) .collect(); if fonts.is_empty() { - fonts = list_system_fonts() - .into_iter() - .map(|f| f.family) - .collect(); + fonts = list_system_fonts().into_iter().map(|f| f.family).collect(); } fonts } @@ -227,9 +357,7 @@ fn find_linux_sound_file_in_dir(dir: &std::path::Path, name: &str) -> Option bool { #[cfg(target_os = "linux")] fn command_in_path(command: &str) -> bool { - std::env::var_os("PATH") - .map(|paths| { - std::env::split_paths(&paths).any(|path| path.join(command).is_file()) - }) - .unwrap_or(false) + command_exists(command) } #[cfg(target_os = "linux")] @@ -290,7 +414,11 @@ fn collect_windows_sounds(dir: &std::path::Path, sounds: &mut std::collections:: collect_windows_sounds(&path, sounds); continue; } - if path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("wav")) { + if path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("wav")) + { if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { sounds.insert(name.to_string()); } @@ -308,10 +436,7 @@ fn play_windows_sound(name: &str) -> Result<(), String> { .args([ "-NoProfile", "-Command", - &format!( - "(New-Object Media.SoundPlayer '{}').PlaySync()", - path.display() - ), + &format!("(New-Object Media.SoundPlayer '{}').PlaySync()", path.display()), ]) .spawn() .map_err(|e| format!("Failed to play sound: {e}"))?; @@ -335,3 +460,29 @@ fn find_windows_sound_file(name: &str) -> Option { } walk(&windows_sounds_dir(), name) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_command_does_not_exist() { + assert!(!command_exists("")); + } + + #[test] + fn sh_or_bash_exists_on_unix() { + assert!(command_exists("sh") || std::path::Path::new("/bin/sh").is_file()); + } + + #[test] + fn list_shells_includes_a_real_shell() { + let shells = list_shells(); + assert!( + shells + .iter() + .any(|s| s.ends_with("/sh") || s.ends_with("/bash") || s.ends_with("/zsh")), + "{shells:?}" + ); + } +} diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index e7783e1a..4304d5ff 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -429,6 +429,32 @@ fn dialog_body( .text_xs() .child(app.data.overlay.dialog_url.clone().unwrap_or_default()), ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("browserOpenWith")), + ) + .child( + h_flex() + .gap_1() + .flex_wrap() + .children(crate::platform::installed_browsers().into_iter().map(|browser| { + let cmd = browser.command; + Button::new(crate::ui::eid(format!("open-with-{cmd}"))) + .xsmall() + .label(browser.id) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.open_url_with(cmd); + cx.notify(); + }); + } + }) + })), + ) .into_any_element(), DialogKind::ChooseFile => v_flex() .gap_2() @@ -524,6 +550,22 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> }); } })) + .when(kind == DialogKind::ReviewQueue, |el| { + el.child( + Button::new("dlg-copy-clear") + .small() + .label(app.t("reviewCommentsCopiedAndCleared")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.copy_review_comments(true, cx); + cx.notify(); + }); + } + }), + ) + }) .when(show_ok, |el| { el.child( Button::new("dlg-ok") @@ -553,10 +595,7 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> app.data.overlay.dialog = None; } DialogKind::OpenLink => { - if let Some(url) = app.data.overlay.dialog_url.clone() { - let _ = open::that(url); - } - app.data.overlay.dialog = None; + app.open_url_with(""); } DialogKind::CreateGroup => { let name = app.inputs.group_name.read(cx).value().to_string(); @@ -569,13 +608,7 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> app.data.overlay.dialog = None; } DialogKind::ReviewQueue => { - let text = app.data.overlay.review_comments.join("\n"); - cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); - app.data.push_toast( - crate::state::ToastKind::Success, - app.t("reviewCommentsCopied"), - "", - ); + app.copy_review_comments(false, cx); } _ => app.data.overlay.dialog = None, } diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 313a62c8..77d9b279 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -28,6 +28,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) } if backend::is_markdown(&file.path) { + let slash = file.draft.lines().last().unwrap_or("").to_string(); return v_flex() .id("markdown-viewer") .size_full() @@ -52,6 +53,16 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .child(md_btn("md-q", ">", "> ", "", &view)) .child(md_btn("md-link", "[]", "[", "](url)", &view)) .child(md_btn("md-img", "img", "![", "](src)", &view)) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(if file.dirty() { + app.t("notesSaving") + } else { + app.t("notesSaved") + }), + ) .child( Button::new("md-save") .xsmall() @@ -68,6 +79,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }), ), ) + .when(slash.starts_with('/'), |el| el.child(slash_menu(&slash, &view))) .child( h_flex() .flex_1() @@ -235,6 +247,60 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> .into_any_element() } +fn slash_menu(query: &str, view: &gpui::Entity) -> impl IntoElement { + let q = query.trim_start_matches('/').to_ascii_lowercase(); + let items: [(&str, &str, &str); 8] = [ + ("h1", "# ", ""), + ("h2", "## ", ""), + ("h3", "### ", ""), + ("ul", "- ", ""), + ("quote", "> ", ""), + ("code", "```\n", "\n```"), + ("link", "[", "](url)"), + ("hr", "---\n", ""), + ]; + h_flex() + .id("md-slash") + .px_2() + .py_1() + .gap_1() + .w(px(180.)) + .max_h(px(280.)) + .flex_wrap() + .children( + items + .into_iter() + .filter(|(name, _, _)| q.is_empty() || name.contains(&q)) + .map(|(name, prefix, suffix)| { + let view = view.clone(); + Button::new(crate::ui::eid(format!("slash-{name}"))) + .ghost() + .xsmall() + .label(format!("/{name}")) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + let text = app.inputs.file_editor.read(cx).value().to_string(); + let mut lines: Vec<&str> = text.lines().collect(); + if let Some(last) = lines.last_mut() { + if last.starts_with('/') { + *last = ""; + } + } + let mut next = lines.join("\n"); + if !next.is_empty() && !next.ends_with('\n') { + next.push('\n'); + } + next.push_str(prefix); + next.push_str(suffix); + app.inputs.file_editor.update(cx, |s, cx| { + s.set_value(next, window, cx); + }); + }); + }) + }), + ) +} + fn md_btn( id: &'static str, label: &'static str, diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 23a10fef..c699d16e 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -409,7 +409,42 @@ impl SettingsView { } }, )) + .when(!self.prefs.sync_terminal_theme, |el| { + el.child(field_label(&self.t("terminalTheme"))) + .child( + v_flex().gap_1().children(TERM_THEMES.iter().map(|t| { + let selected = self.prefs.terminal_theme_light == t.name; + choice(format!("light-{name}", name = t.name), t.name, selected, { + let view = view.clone(); + let name = t.name.to_string(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.terminal_theme_light = name.clone(); + this.persist(cx); + cx.notify(); + }); + } + }) + })), + ) + }) .child(field_label(&self.t("defaultShell"))) + .child( + h_flex().gap_1().flex_wrap().children(crate::platform::list_shells().into_iter().map(|shell| { + let selected = self.prefs.custom_shell == shell; + choice(format!("shell-{shell}"), &shell, selected, { + let view = view.clone(); + let shell = shell.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.custom_shell = shell.clone(); + this.persist(cx); + cx.notify(); + }); + } + }) + })), + ) .child(Input::new(&self.custom_shell)) .child(field_label(&self.t("terminalFont"))) .child(div().text_sm().child(self.prefs.font_family.clone())) @@ -719,6 +754,65 @@ impl SettingsView { }, ) })) + .child(field_label(&self.t("topbarEditorApp"))) + .child(if crate::platform::installed_editors().is_empty() { + div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDetectingApps")).into_any_element() + } else { + h_flex() + .gap_1() + .flex_wrap() + .children(crate::platform::installed_editors().into_iter().map(|app| { + let selected = self.prefs.editor_app == app.id || self.prefs.editor_app == app.command; + let label = match app.id { + "vscode" => self.t("topbarVscode"), + "cursor" => self.t("topbarCursor"), + "windsurf" => self.t("topbarWindsurf"), + "zed" => self.t("topbarZed"), + _ => self.t("topbarSublimeText"), + }; + choice(format!("ed-{id}", id = app.id), &label, selected, { + let view = view.clone(); + let id = app.id.to_string(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.editor_app = id.clone(); + this.persist(cx); + cx.notify(); + }); + } + }) + })) + .into_any_element() + }) + .child(field_label(&self.t("topbarTerminalApp"))) + .child(if crate::platform::installed_terminals().is_empty() { + div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDetectingApps")).into_any_element() + } else { + h_flex() + .gap_1() + .flex_wrap() + .children(crate::platform::installed_terminals().into_iter().map(|app| { + let selected = self.prefs.terminal_app == app.id; + let label = match app.id { + "ghostty" => self.t("topbarGhostty"), + "iterm2" => self.t("topbarIterm2"), + "kitty" => self.t("topbarKitty"), + _ => self.t("topbarWarp"), + }; + choice(format!("termapp-{id}", id = app.id), &label, selected, { + let view = view.clone(); + let id = app.id.to_string(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.terminal_app = id.clone(); + this.persist(cx); + cx.notify(); + }); + } + }) + })) + .into_any_element() + }) .child( Button::new("reset-topbar") .label(self.t("topbarResetDefaults")) diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index 9946c08b..67cd24a3 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -187,10 +187,11 @@ pub fn render(app: &mut AppView, index: usize, _window: &mut Window, cx: &mut Co Button::new(crate::ui::eid(format!("pty-link-{label}"))) .xsmall() .label(label) - .on_click(move |_, window, cx| { + .on_click(move |ev, window, cx| { let token = token.clone(); + let skip = ev.modifiers().platform || ev.modifiers().control; view.update(cx, |app, cx| { - app.open_clickable(&token, window, cx); + app.open_clickable(&token, skip, window, cx); cx.notify(); }); }) From 9584903bc0b988c34eee61498136a004388db228 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 06:22:44 +0000 Subject: [PATCH 11/50] Add GPUI create-project polish, settings edit, and file-viewer fidelity. Close more inventory gaps: dashed folder picker with basename fill and busy spinner, show-all-fonts and empty-sound states, click-to-edit templates, text line numbers, image checkerboard, and a GPUI CI check job. Co-authored-by: AkaraChen --- .github/workflows/unit-tests.yml | 38 +++++++ justfile | 2 +- src-gpui/src/app.rs | 123 ++++++++++++++++++++--- src-gpui/src/backend.rs | 27 +++++ src-gpui/src/platform.rs | 41 +++++++- src-gpui/src/prefs.rs | 147 ++++++++++++++++++++++++--- src-gpui/src/ui/dialogs.rs | 72 ++++++++++++-- src-gpui/src/ui/file_viewer.rs | 129 +++++++++++++++++++++--- src-gpui/src/ui/settings.rs | 166 +++++++++++++++++++++++-------- 9 files changed, 646 insertions(+), 99 deletions(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index b9e99424..98d5c00c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -102,3 +102,41 @@ jobs: - name: Run Rust tests run: cargo test --manifest-path src-tauri/Cargo.toml + + gpui: + name: GPUI check + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Linux dependencies + run: | + sudo apt update + sudo apt install -y \ + build-essential \ + pkg-config \ + libssl-dev \ + libsqlite3-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libwayland-dev \ + libvulkan-dev \ + libx11-dev \ + libxcursor-dev \ + libxi-dev \ + libxrandr-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + workspaces: | + src-gpui -> target + + - name: Check GPUI crate + run: cargo check --tests --manifest-path src-gpui/Cargo.toml diff --git a/justfile b/justfile index 4011952e..7653efeb 100644 --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ gpui: cd src-gpui && cargo run gpui-check: - cd src-gpui && cargo check + cd src-gpui && cargo check --tests start: just gpui diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index f561fa6f..36e358b0 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -1052,17 +1052,23 @@ impl AppView { self.reload_projects(); } + pub fn apply_picked_folder(&mut self, folder: String, window: &mut Window, cx: &mut Context) { + let empty = self.inputs.project_name.read(cx).value().trim().is_empty(); + self.data.overlay.dialog_folder = Some(folder.clone()); + if empty { + let name = suggested_project_name(&folder, ""); + self.inputs.project_name.update(cx, |s, cx| { + s.set_value(name, window, cx); + }); + } + } + pub fn create_project_from_dialog(&mut self, window: &mut Window, cx: &mut Context) { let folder = match self.data.overlay.dialog_folder.clone() { Some(f) => f, None => return, }; - let name = self.inputs.project_name.read(cx).value().to_string(); - let name = if name.trim().is_empty() { - backend::file_name(&folder) - } else { - name - }; + let name = suggested_project_name(&folder, &self.inputs.project_name.read(cx).value()); self.data.overlay.dialog_busy = true; match self.backend.create_project(&name, &folder) { Ok(project) => { @@ -1369,6 +1375,35 @@ impl AppView { } pub fn add_project_template(&mut self, cx: &mut Context) { + self.upsert_project_template(cx); + } + + pub fn load_project_template(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + let Some(template) = self + .data + .workspaces + .values() + .find(|w| { + Some(w.project_id.as_str()) == self.data.overlay.dialog_project.as_deref() + || Some(w.project_id.as_str()) == self.data.current_project.as_deref() + }) + .and_then(|w| w.config.terminal_templates.iter().find(|t| t.id == id).cloned()) + else { + return; + }; + self.data.overlay.editing_template = Some(id.to_string()); + self.inputs.template_name.update(cx, |s, cx| { + s.set_value(template.name.clone(), window, cx); + }); + self.inputs.template_cwd.update(cx, |s, cx| { + s.set_value(template.cwd.clone(), window, cx); + }); + self.inputs.template_commands.update(cx, |s, cx| { + s.set_value(template.commands.join("\n"), window, cx); + }); + } + + pub fn upsert_project_template(&mut self, cx: &mut Context) { let name = self.inputs.template_name.read(cx).value().to_string(); if name.trim().is_empty() { return; @@ -1382,15 +1417,60 @@ impl AppView { else { return; }; - let template = model::project::ProjectTerminalTemplate { - id: uuid::Uuid::new_v4().to_string(), - name, - cwd: self.inputs.template_cwd.read(cx).value().to_string(), - commands: lines(&self.inputs.template_commands.read(cx).value()), - }; + let editing = self.data.overlay.editing_template.clone(); + let cwd = self.inputs.template_cwd.read(cx).value().to_string(); + let commands = lines(&self.inputs.template_commands.read(cx).value()); if let Some(ws) = self.data.workspaces.values_mut().find(|w| w.project_id == project_id) { - ws.config.terminal_templates.push(template); + if let Some(id) = editing.as_deref() { + if let Some(template) = ws.config.terminal_templates.iter_mut().find(|t| t.id == id) { + template.name = name; + template.cwd = cwd; + template.commands = commands; + self.data.overlay.editing_template = None; + return; + } + } + ws.config + .terminal_templates + .push(model::project::ProjectTerminalTemplate { + id: uuid::Uuid::new_v4().to_string(), + name, + cwd, + commands, + }); } + self.data.overlay.editing_template = None; + } + + pub fn save_editing_template(&mut self, cx: &mut Context) { + let name = self.inputs.template_name.read(cx).value().to_string(); + if name.trim().is_empty() { + return; + } + let id = self.data.overlay.editing_template.clone(); + let shell = self.inputs.template_shell.read(cx).value().to_string(); + let cwd = self.inputs.template_cwd.read(cx).value().to_string(); + let commands = lines(&self.inputs.template_commands.read(cx).value()); + if id.as_ref().is_some_and(|id| { + self.data + .workspaces + .values() + .any(|w| w.config.terminal_templates.iter().any(|t| t.id == *id)) + }) { + self.upsert_project_template(cx); + } else { + crate::prefs::upsert_template( + &mut self.data.prefs.templates, + id.as_deref(), + name, + shell, + cwd, + commands, + ); + self.persist_prefs(); + } + self.data.overlay.editing_template = None; + self.data.overlay.dialog = None; } pub fn remove_project_template(&mut self, id: &str) { @@ -2146,9 +2226,17 @@ pub fn input_el(state: &Entity) -> Input { Input::new(state) } +pub fn suggested_project_name(folder: &str, current_name: &str) -> String { + if current_name.trim().is_empty() { + backend::file_name(folder) + } else { + current_name.to_string() + } +} + #[cfg(test)] mod tests { - use super::unique_tree_name; + use super::{suggested_project_name, unique_tree_name}; #[test] fn unique_tree_name_adds_numbers() { @@ -2159,4 +2247,11 @@ mod tests { "New File 3" ); } + + #[test] + fn suggested_project_name_uses_basename_when_empty() { + assert_eq!(suggested_project_name("/tmp/my-app", ""), "my-app"); + assert_eq!(suggested_project_name("/tmp/my-app", " "), "my-app"); + assert_eq!(suggested_project_name("/tmp/my-app", "Custom"), "Custom"); + } } diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index 02c9b045..31ff8c42 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -569,9 +569,36 @@ pub fn is_markdown(path: &str) -> bool { lower.ends_with(".md") || lower.ends_with(".mdx") } +pub fn is_pdf(path: &str) -> bool { + path.to_ascii_lowercase().ends_with(".pdf") +} + +pub fn is_office(path: &str) -> bool { + let lower = path.to_ascii_lowercase(); + lower.ends_with(".docx") || lower.ends_with(".xlsx") || lower.ends_with(".pptx") +} + +pub fn is_document_preview(kind: &str, path: &str) -> bool { + matches!(kind, "pdf" | "office-pdf" | "office") || is_pdf(path) || is_office(path) +} + pub fn file_name(path: &str) -> String { Path::new(path) .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document_preview_kinds() { + assert!(is_document_preview("pdf", "notes.txt")); + assert!(is_document_preview("office-pdf", "slide.pptx")); + assert!(is_document_preview("", "spec.pdf")); + assert!(is_document_preview("", "brief.docx")); + assert!(!is_document_preview("", "readme.md")); + } +} diff --git a/src-gpui/src/platform.rs b/src-gpui/src/platform.rs index 3ea890a5..575cd5ba 100644 --- a/src-gpui/src/platform.rs +++ b/src-gpui/src/platform.rs @@ -145,17 +145,29 @@ pub fn list_system_fonts() -> Vec { } pub fn list_mono_fonts() -> Vec { - let mut fonts: Vec = list_system_fonts() - .into_iter() - .filter(|f| f.is_mono) - .map(|f| f.family) - .collect(); + let mut fonts: Vec = filter_fonts(&list_system_fonts(), false); if fonts.is_empty() { fonts = list_system_fonts().into_iter().map(|f| f.family).collect(); } fonts } +pub fn visible_font_families(show_all: bool) -> Vec { + if show_all { + list_system_fonts().into_iter().map(|f| f.family).collect() + } else { + list_mono_fonts() + } +} + +pub fn filter_fonts(fonts: &[SystemFont], show_all: bool) -> Vec { + fonts + .iter() + .filter(|font| show_all || font.is_mono) + .map(|font| font.family.clone()) + .collect() +} + fn load_fonts() -> Vec { let mut db = fontdb::Database::new(); db.load_system_fonts(); @@ -485,4 +497,23 @@ mod tests { "{shells:?}" ); } + + #[test] + fn filter_fonts_respects_show_all() { + let fonts = [ + SystemFont { + family: "Menlo".into(), + is_mono: true, + }, + SystemFont { + family: "Inter".into(), + is_mono: false, + }, + ]; + assert_eq!(filter_fonts(&fonts, false), vec!["Menlo".to_string()]); + assert_eq!( + filter_fonts(&fonts, true), + vec!["Menlo".to_string(), "Inter".to_string()] + ); + } } diff --git a/src-gpui/src/prefs.rs b/src-gpui/src/prefs.rs index 42fd5ae3..77fd0cf8 100644 --- a/src-gpui/src/prefs.rs +++ b/src-gpui/src/prefs.rs @@ -88,6 +88,8 @@ pub struct Prefs { pub accept_beta: bool, pub templates: Vec, pub collapsed_projects: Vec, + #[serde(default)] + pub show_all_fonts: bool, } impl Default for Prefs { @@ -125,6 +127,7 @@ impl Default for Prefs { accept_beta: false, templates: Vec::new(), collapsed_projects: Vec::new(), + show_all_fonts: false, } } } @@ -156,6 +159,73 @@ impl Prefs { } } +pub fn upsert_template( + templates: &mut Vec, + id: Option<&str>, + name: String, + shell: String, + cwd: String, + commands: Vec, +) { + if let Some(id) = id { + if let Some(template) = templates.iter_mut().find(|t| t.id == id) { + template.name = name; + template.shell = shell; + template.cwd = cwd; + template.commands = commands; + return; + } + } + templates.push(TerminalTemplatePref { + id: uuid::Uuid::new_v4().to_string(), + name, + shell, + cwd, + commands, + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upsert_template_adds_then_updates() { + let mut templates = Vec::new(); + upsert_template( + &mut templates, + None, + "Dev".into(), + "/bin/zsh".into(), + ".".into(), + vec!["npm run dev".into()], + ); + assert_eq!(templates.len(), 1); + let id = templates[0].id.clone(); + upsert_template( + &mut templates, + Some(&id), + "Dev Server".into(), + "/bin/bash".into(), + "apps/web".into(), + vec!["pnpm dev".into()], + ); + assert_eq!(templates.len(), 1); + assert_eq!(templates[0].name, "Dev Server"); + assert_eq!(templates[0].shell, "/bin/bash"); + assert_eq!(templates[0].cwd, "apps/web"); + assert_eq!(templates[0].commands, vec!["pnpm dev".to_string()]); + } + + #[test] + fn missing_show_all_fonts_defaults_false() { + let raw = r#"{"language":"en","theme":"system","radius":"medium","worktree_dir":"","debug_mode":false,"performance_profile":false,"show_avatars":true,"sidebar_width":250.0,"sidebar_collapsed":false,"collapsed_groups":[],"profile_sidebar_width":208.0,"profile_sidebar_open":true,"terminal_theme_dark":"GitHub Dark","terminal_theme_light":"GitHub Light","sync_terminal_theme":false,"font_family":"Menlo","font_size":13.0,"default_shell":"","custom_shell":"","notifications":true,"notification_sound":"","topbar_controls":[],"editor_app":"","terminal_app":"","accept_beta":false,"templates":[],"collapsed_projects":[]}"#; + let prefs: Prefs = serde_json::from_str(raw).expect("old prefs still load"); + assert!(!prefs.show_all_fonts); + assert_eq!(prefs.font_family, "Menlo"); + } +} + #[derive(Debug, Clone, Copy)] pub struct TermTheme { pub name: &'static str, @@ -166,20 +236,71 @@ pub struct TermTheme { } pub const TERM_THEMES: &[TermTheme] = &[ - TermTheme { name: "GitHub Dark", bg: 0x0d1117, fg: 0xc9d1d9, cursor: 0x58a6ff, is_light: false }, - TermTheme { name: "GitHub Light", bg: 0xffffff, fg: 0x1f2328, cursor: 0x0969da, is_light: true }, - TermTheme { name: "Dracula", bg: 0x282a36, fg: 0xf8f8f2, cursor: 0xff79c6, is_light: false }, - TermTheme { name: "Ayu Dark", bg: 0x0a0e14, fg: 0xb3b1ad, cursor: 0xffb454, is_light: false }, - TermTheme { name: "Ayu Light", bg: 0xfafafa, fg: 0x5c6773, cursor: 0xff6a00, is_light: true }, - TermTheme { name: "Solarized Dark", bg: 0x002b36, fg: 0x839496, cursor: 0x268bd2, is_light: false }, - TermTheme { name: "Solarized Light", bg: 0xfdf6e3, fg: 0x657b83, cursor: 0x268bd2, is_light: true }, - TermTheme { name: "One Dark", bg: 0x282c34, fg: 0xabb2bf, cursor: 0x61afef, is_light: false }, - TermTheme { name: "One Light", bg: 0xfafafa, fg: 0x383a42, cursor: 0x4078f2, is_light: true }, + TermTheme { + name: "GitHub Dark", + bg: 0x0d1117, + fg: 0xc9d1d9, + cursor: 0x58a6ff, + is_light: false, + }, + TermTheme { + name: "GitHub Light", + bg: 0xffffff, + fg: 0x1f2328, + cursor: 0x0969da, + is_light: true, + }, + TermTheme { + name: "Dracula", + bg: 0x282a36, + fg: 0xf8f8f2, + cursor: 0xff79c6, + is_light: false, + }, + TermTheme { + name: "Ayu Dark", + bg: 0x0a0e14, + fg: 0xb3b1ad, + cursor: 0xffb454, + is_light: false, + }, + TermTheme { + name: "Ayu Light", + bg: 0xfafafa, + fg: 0x5c6773, + cursor: 0xff6a00, + is_light: true, + }, + TermTheme { + name: "Solarized Dark", + bg: 0x002b36, + fg: 0x839496, + cursor: 0x268bd2, + is_light: false, + }, + TermTheme { + name: "Solarized Light", + bg: 0xfdf6e3, + fg: 0x657b83, + cursor: 0x268bd2, + is_light: true, + }, + TermTheme { + name: "One Dark", + bg: 0x282c34, + fg: 0xabb2bf, + cursor: 0x61afef, + is_light: false, + }, + TermTheme { + name: "One Light", + bg: 0xfafafa, + fg: 0x383a42, + cursor: 0x4078f2, + is_light: true, + }, ]; pub fn term_theme_by_name(name: &str) -> &'static TermTheme { - TERM_THEMES - .iter() - .find(|t| t.name == name) - .unwrap_or(&TERM_THEMES[0]) + TERM_THEMES.iter().find(|t| t.name == name).unwrap_or(&TERM_THEMES[0]) } diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index 4304d5ff..46742b34 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -1,6 +1,7 @@ use gpui::{div, prelude::*, px, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; +use gpui_component::spinner::Spinner; use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; use gpui_component::{Disableable, Selectable}; @@ -82,7 +83,22 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> .child( h_flex() .justify_between() - .child(div().font_semibold().child(title)) + .child( + h_flex() + .gap_2() + .items_center() + .when(kind == DialogKind::CreateProject, |el| { + el.child(Icon::new(IconName::Folder).w(px(18.))) + }) + .when(kind == DialogKind::ProjectSettings, |el| { + el.child(Icon::new(IconName::Settings).w(px(18.))) + }) + .when( + kind == DialogKind::DeleteProfile || kind == DialogKind::DeleteProject, + |el| el.child(Icon::new(IconName::TriangleAlert).w(px(18.))), + ) + .child(div().font_semibold().child(title)), + ) .child(Button::new("dlg-x").ghost().xsmall().icon(IconName::Close).on_click({ let view = view.clone(); move |_, _, cx| { @@ -128,10 +144,10 @@ fn dialog_body( .child(div().text_sm().child(app.t("folder"))) .child(Button::new("rechoose").xsmall().label(app.t("chooseFolder")).on_click({ let view = view.clone(); - move |_, _, cx| { + move |_, window, cx| { view.update(cx, |app, cx| { if let Some(p) = backend::pick_folder() { - app.data.overlay.dialog_folder = Some(p); + app.apply_picked_folder(p, window, cx); } cx.notify(); }); @@ -155,6 +171,7 @@ fn dialog_body( .h(px(88.)) .rounded_lg() .border_1() + .border_dashed() .border_color(theme.border) .flex() .flex_col() @@ -164,10 +181,10 @@ fn dialog_body( .hover(|el| el.bg(theme.muted)) .on_click({ let view = view.clone(); - move |_, _, cx| { + move |_, window, cx| { view.update(cx, |app, cx| { if let Some(p) = backend::pick_folder() { - app.data.overlay.dialog_folder = Some(p); + app.apply_picked_folder(p, window, cx); } cx.notify(); }); @@ -280,10 +297,22 @@ fn dialog_body( .justify_between() .child( v_flex() + .id(crate::ui::eid(format!("ptpl-edit-{id}"))) + .cursor(gpui::CursorStyle::PointingHand) .child(div().font_medium().text_sm().child(t.name.clone())) .child( div().text_xs().text_color(theme.muted_foreground).child(t.cwd.clone()), - ), + ) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.load_project_template(&id, window, cx); + cx.notify(); + }); + } + }), ) .child( Button::new(crate::ui::eid(format!("ptpl-del-{id}"))) @@ -313,7 +342,11 @@ fn dialog_body( Button::new("add-project-tpl") .xsmall() .primary() - .label(app.t("addTerminalTemplate")) + .label(if app.data.overlay.editing_template.is_some() { + app.t("save") + } else { + app.t("addTerminalTemplate") + }) .on_click({ let view = view.clone(); move |_, _, cx| { @@ -336,7 +369,13 @@ fn dialog_body( .gap_2() .child(div().text_sm().child(app.t("confirmDeleteProfile"))) .when(app.data.overlay.dialog_busy, |el| { - el.child(div().text_xs().child(app.t("deleteProfileCheckingGitStatus"))) + el.child( + h_flex() + .gap_2() + .items_center() + .child(Spinner::new().with_size(gpui_component::Size::Small)) + .child(div().text_xs().child(app.t("deleteProfileCheckingGitStatus"))), + ) }) .when_some(app.data.overlay.delete_warning.clone(), |el, warn| { el.child( @@ -531,6 +570,7 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> DialogKind::OpenLink => app.t("browserOpenDefault"), DialogKind::CreateGroup => app.t("create"), DialogKind::ReviewQueue => app.t("reviewCommentsCopied"), + DialogKind::EditTemplate => app.t("save"), _ => app.t("cancel"), }; let show_ok = !matches!( @@ -566,6 +606,9 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> }), ) }) + .when(app.data.overlay.dialog_busy && show_ok, |el| { + el.child(Spinner::new().with_size(gpui_component::Size::Small)) + }) .when(show_ok, |el| { el.child( Button::new("dlg-ok") @@ -573,7 +616,17 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> .when(danger, |b| b.danger()) .when(!danger, |b| b.primary()) .label(ok) - .disabled(matches!(kind, DialogKind::CreateProject) && app.data.overlay.dialog_folder.is_none()) + .disabled( + (matches!(kind, DialogKind::CreateProject) + && (app.data.overlay.dialog_folder.is_none() || app.data.overlay.dialog_busy)) + || (app.data.overlay.dialog_busy + && matches!( + kind, + DialogKind::DeleteProject + | DialogKind::DeleteProfile | DialogKind::ProjectSettings + | DialogKind::CreateProfile + )), + ) .on_click({ let view = view.clone(); move |_, window, cx| { @@ -585,6 +638,7 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> DialogKind::ProjectSettings => app.save_project_settings(cx), DialogKind::CreateProfile => app.create_profile_from_dialog(cx), DialogKind::DeleteProfile => app.delete_dialog_profile(), + DialogKind::EditTemplate => app.save_editing_template(cx), DialogKind::CloseUnsaved => { if let (Some(pid), Some(path)) = ( app.data.current_profile.clone(), diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 77d9b279..8876ae4c 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -140,7 +140,13 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }), ), ) - .child(div().flex_1().min_h_0().child(Input::new(&app.inputs.file_editor))) + .child( + h_flex() + .flex_1() + .min_h_0() + .child(line_gutter(&file.draft, app.data.prefs.font_size, &theme)) + .child(div().flex_1().min_h_0().child(Input::new(&app.inputs.file_editor))), + ) .into_any_element() } @@ -162,11 +168,16 @@ fn preview_pane(_app: &AppView, file: &crate::state::OpenFileTab, cx: &mut Conte .child( h_flex() .gap_2() - .child(div().text_xs().child(if file.preview_kind == "office" { - "Office Preview".to_string() - } else { - "Preview".to_string() - })) + .child(div().text_xs().child( + if backend::is_document_preview(&file.preview_kind, &file.path) + && !backend::is_pdf(&file.path) + && file.preview_kind != "pdf" + { + "Office Preview".to_string() + } else { + "Preview".to_string() + }, + )) .child(Button::new("open-external").xsmall().label("Open").on_click({ let view = view.clone(); let path = path.clone(); @@ -190,18 +201,40 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> return div() .id("image-preview") .size_full() - .flex() - .items_center() - .justify_center() - .bg(rgb(0x1b1f23)) + .relative() + .child(checkerboard()) .child( - img(PathBuf::from(src)) - .id("image-preview-img") - .max_w_full() - .max_h_full(), + div().absolute().inset_0().flex().items_center().justify_center().child( + img(PathBuf::from(src)) + .id("image-preview-img") + .max_w_full() + .max_h_full(), + ), ) .into_any_element(); } + if backend::is_document_preview(&file.preview_kind, &file.path) { + let path = file.path.clone(); + return v_flex() + .id("document-preview") + .size_full() + .bg(rgb(0xffffff)) + .items_center() + .justify_center() + .gap_2() + .child(div().text_sm().text_color(rgb(0x1f2328)).child(file.title.clone())) + .child(div().text_xs().text_color(rgb(0x656d76)).child( + if file.preview_kind == "office-pdf" || backend::is_office(&file.path) { + "Office Preview".to_string() + } else { + "Preview".to_string() + }, + )) + .child(Button::new("doc-open").small().label("Open").on_click(move |_, _, _| { + let _ = open::that(&path); + })) + .into_any_element(); + } if file.preview_kind == "archive" && !file.archive_entries.is_empty() { let files = file.archive_entries.iter().filter(|(_, k)| k != "dir").count(); let folders = file.archive_entries.iter().filter(|(_, k)| k == "dir").count(); @@ -247,6 +280,57 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> .into_any_element() } +pub fn line_number_count(text: &str) -> usize { + if text.is_empty() { + 1 + } else { + text.lines().count().max(1) + } +} + +fn line_gutter(text: &str, font_size: f32, theme: &gpui_component::Theme) -> impl IntoElement { + let count = line_number_count(text); + let line_h = px((font_size * 1.4).max(16.0)); + v_flex() + .id("line-gutter") + .w(px(48.)) + .h_full() + .py(px(12.)) + .px_1() + .bg(theme.muted) + .text_color(theme.muted_foreground) + .text_xs() + .font_family("monospace") + .children((1..=count).map(move |n| { + div() + .h(line_h) + .w_full() + .flex() + .justify_end() + .pr_1() + .child(format!("{n}")) + })) +} + +fn checkerboard() -> impl IntoElement { + let light = rgb(0x2a2e33); + let dark = rgb(0x1b1f23); + let cell = px(16.); + v_flex() + .id("image-checkerboard") + .absolute() + .inset_0() + .overflow_hidden() + .children((0..24).map(move |row| { + h_flex().children((0..40).map(move |col| { + div() + .w(cell) + .h(cell) + .bg(if (row + col) % 2 == 0 { light } else { dark }) + })) + })) +} + fn slash_menu(query: &str, view: &gpui::Entity) -> impl IntoElement { let q = query.trim_start_matches('/').to_ascii_lowercase(); let items: [(&str, &str, &str); 8] = [ @@ -327,3 +411,20 @@ fn md_btn( }); }) } + +#[cfg(test)] +mod tests { + use super::line_number_count; + + #[test] + fn line_number_count_empty_is_one() { + assert_eq!(line_number_count(""), 1); + } + + #[test] + fn line_number_count_counts_lines() { + assert_eq!(line_number_count("a"), 1); + assert_eq!(line_number_count("a\nb"), 2); + assert_eq!(line_number_count("a\nb\nc"), 3); + } +} diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index c699d16e..3832b4b6 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -101,6 +101,7 @@ pub struct SettingsView { latest_url: String, fonts: Vec, sounds: Vec, + editing_template_id: Option, } impl SettingsView { @@ -154,6 +155,7 @@ impl SettingsView { latest_url: crate::updater::releases_page().to_string(), fonts: crate::platform::list_mono_fonts(), sounds: crate::platform::list_system_sounds(), + editing_template_id: None, } } @@ -448,12 +450,33 @@ impl SettingsView { .child(Input::new(&self.custom_shell)) .child(field_label(&self.t("terminalFont"))) .child(div().text_sm().child(self.prefs.font_family.clone())) + .child(switch_row( + "show-all-fonts", + self.t("showAllFonts"), + String::new(), + self.prefs.show_all_fonts, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.show_all_fonts = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) .child( div() - .max_h(px(140.)) + .max_h(px(180.)) .overflow_y_hidden() - .child( - h_flex().gap_1().flex_wrap().children(self.fonts.iter().cloned().take(48).map(|family| { + .child({ + let fonts = if self.prefs.show_all_fonts { + crate::platform::visible_font_families(true) + } else { + self.fonts.clone() + }; + h_flex().gap_1().flex_wrap().children(fonts.into_iter().map(|family| { let selected = self.prefs.font_family == family; choice(format!("font-{family}"), &family, selected, { let view = view.clone(); @@ -466,8 +489,8 @@ impl SettingsView { }); } }) - })), - ), + })) + }), ) .child(field_label(&self.t("fontSize"))) .child( @@ -521,7 +544,8 @@ impl SettingsView { ) } - fn templates(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn templates(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let _ = window; let view = cx.entity(); v_flex() .id("settings-templates") @@ -535,17 +559,46 @@ impl SettingsView { v_flex() .gap_2() .children(self.prefs.templates.iter().cloned().map(|t| { + let selected = self.editing_template_id.as_deref() == Some(t.id.as_str()); h_flex() .id(crate::ui::eid(format!("tpl-{}", t.id))) .justify_between() .p_2() .rounded_md() .border_1() - .border_color(cx.theme().border) + .border_color(if selected { + cx.theme().accent + } else { + cx.theme().border + }) .child( v_flex() + .id(crate::ui::eid(format!("tpl-edit-{}", t.id))) + .cursor(gpui::CursorStyle::PointingHand) .child(div().font_medium().child(t.name.clone())) - .child(div().text_xs().child(format!("{} · {}", t.shell, t.cwd))), + .child(div().text_xs().child(format!("{} · {}", t.shell, t.cwd))) + .on_click({ + let view = view.clone(); + let t = t.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| { + this.editing_template_id = Some(t.id.clone()); + this.template_name.update(cx, |s, cx| { + s.set_value(t.name.clone(), window, cx); + }); + this.template_shell.update(cx, |s, cx| { + s.set_value(t.shell.clone(), window, cx); + }); + this.template_cwd.update(cx, |s, cx| { + s.set_value(t.cwd.clone(), window, cx); + }); + this.template_cmds.update(cx, |s, cx| { + s.set_value(t.commands.join("\n"), window, cx); + }); + cx.notify(); + }); + } + }), ) .child( Button::new(crate::ui::eid(format!("del-{}", t.id))) @@ -558,6 +611,9 @@ impl SettingsView { move |_, _, cx| { view.update(cx, |this, cx| { this.prefs.templates.retain(|x| x.id != id); + if this.editing_template_id.as_deref() == Some(id.as_str()) { + this.editing_template_id = None; + } this.persist(cx); cx.notify(); }); @@ -578,21 +634,26 @@ impl SettingsView { .child( Button::new("add-tpl") .primary() - .label(self.t("addTerminalTemplate")) + .label(if self.editing_template_id.is_some() { + self.t("save") + } else { + self.t("addTerminalTemplate") + }) .on_click({ let view = view.clone(); - move |_, _, cx| { + move |_, window, cx| { view.update(cx, |this, cx| { let name = this.template_name.read(cx).value().to_string(); if name.trim().is_empty() { return; } - this.prefs.templates.push(crate::prefs::TerminalTemplatePref { - id: uuid::Uuid::new_v4().to_string(), + crate::prefs::upsert_template( + &mut this.prefs.templates, + this.editing_template_id.as_deref(), name, - shell: this.template_shell.read(cx).value().to_string(), - cwd: this.template_cwd.read(cx).value().to_string(), - commands: this + this.template_shell.read(cx).value().to_string(), + this.template_cwd.read(cx).value().to_string(), + this .template_cmds .read(cx) .value() @@ -600,7 +661,12 @@ impl SettingsView { .map(|l| l.to_string()) .filter(|l| !l.is_empty()) .collect(), - }); + ); + this.editing_template_id = None; + this.template_name.update(cx, |s, cx| s.set_value("", window, cx)); + this.template_shell.update(cx, |s, cx| s.set_value("", window, cx)); + this.template_cwd.update(cx, |s, cx| s.set_value("", window, cx)); + this.template_cmds.update(cx, |s, cx| s.set_value("", window, cx)); this.persist(cx); cx.notify(); }); @@ -636,33 +702,47 @@ impl SettingsView { } else { self.prefs.notification_sound.clone() })) - .child( - h_flex().gap_1().flex_wrap().children( - std::iter::once(String::new()) - .chain(self.sounds.iter().cloned()) - .take(36) - .map(|name| { - let selected = self.prefs.notification_sound == name; - let label = if name.is_empty() { - self.t("notificationSoundNone") - } else { - name.clone() - }; - choice(format!("snd-{name}"), &label, selected, { - let view = view.clone(); - let name = name.clone(); - move |cx| { - view.update(cx, |this, cx| { - this.prefs.notification_sound = name.clone(); - let _ = crate::platform::play_system_sound(&name); - this.persist(cx); - cx.notify(); - }); - } - }) - }), - ), - ) + .child(if self.sounds.is_empty() { + v_flex() + .gap_1() + .p_3() + .rounded_md() + .border_1() + .border_color(cx.theme().border) + .child(div().text_sm().child(self.t("soundPickerUnavailable"))) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("soundPickerUnavailableDescription")), + ) + .into_any_element() + } else { + h_flex() + .gap_1() + .flex_wrap() + .children(std::iter::once(String::new()).chain(self.sounds.iter().cloned()).map(|name| { + let selected = self.prefs.notification_sound == name; + let label = if name.is_empty() { + self.t("notificationSoundNone") + } else { + name.clone() + }; + choice(format!("snd-{name}"), &label, selected, { + let view = view.clone(); + let name = name.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.notification_sound = name.clone(); + let _ = crate::platform::play_system_sound(&name); + this.persist(cx); + cx.notify(); + }); + } + }) + })) + .into_any_element() + }) } fn topbar(&mut self, cx: &mut Context) -> impl IntoElement { From 78128731b80780a35c8fda840bfb672f2a19fe20 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 06:27:18 +0000 Subject: [PATCH 12/50] Add GPUI code-editor highlighting and inventory chrome polish. Switch the text viewer to gpui-component's code editor (tree-sitter languages, line numbers, no wrap), show the first-run tour as a right-side popover after 300ms, and add Windows / project-name tooltips plus file-tree open animation. Co-authored-by: AkaraChen --- src-gpui/Cargo.lock | 315 ++++++++++++++++++++++++++++++++- src-gpui/Cargo.toml | 4 +- src-gpui/src/app.rs | 40 ++++- src-gpui/src/backend.rs | 42 +++++ src-gpui/src/ui/file_tree.rs | 21 ++- src-gpui/src/ui/file_viewer.rs | 31 +--- src-gpui/src/ui/git.rs | 4 + src-gpui/src/ui/shell.rs | 26 ++- src-gpui/src/ui/sidebar.rs | 87 +++++---- src-gpui/src/ui/workspace.rs | 19 +- 10 files changed, 490 insertions(+), 99 deletions(-) diff --git a/src-gpui/Cargo.lock b/src-gpui/Cargo.lock index 04f1003a..e402db63 100644 --- a/src-gpui/Cargo.lock +++ b/src-gpui/Cargo.lock @@ -791,9 +791,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.4" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -948,6 +948,7 @@ name = "code-gpui" version = "3.0.1" dependencies = [ "anyhow", + "cc", "diesel", "dirs 6.0.0", "fontdb 0.23.0", @@ -2443,7 +2444,35 @@ dependencies = [ "smol", "tracing", "tree-sitter", + "tree-sitter-bash", + "tree-sitter-c", + "tree-sitter-c-sharp", + "tree-sitter-cmake", + "tree-sitter-cpp", + "tree-sitter-css", + "tree-sitter-diff", + "tree-sitter-elixir", + "tree-sitter-embedded-template", + "tree-sitter-go", + "tree-sitter-graphql", + "tree-sitter-html", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-jsdoc", "tree-sitter-json", + "tree-sitter-make", + "tree-sitter-md", + "tree-sitter-proto", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-scala", + "tree-sitter-sequel", + "tree-sitter-swift", + "tree-sitter-toml-ng", + "tree-sitter-typescript", + "tree-sitter-yaml", + "tree-sitter-zig", "unicode-segmentation", "uuid", "zed-sum-tree", @@ -6274,7 +6303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand 2.5.0", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -6719,6 +6748,156 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-bash" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "329a4d48623ac337d42b1df84e81a1c9dbb2946907c102ca72db158c1964a52e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1aac67f1ad71de1d6d39708d34811081c26dfa495658de6c14c34200849357c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cmake" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "164e0c4f4236ec5ceff14824a5528615cf462e100467e49826442ff57d327061" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-css" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad6489794d41350d12a7fbe520e5199f688618f43aace5443980d1ddcf1b29e" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-diff" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfe1e5ca280a65dfe5ba4205c1bcc84edf486464fed315db53dee6da9a335889" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-elixir" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66dd064a762ed95bfc29857fa3cb7403bb1e5cb88112de0f6341b7e47284ba40" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-embedded-template" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790063ef14e5b67556abc0b3be0ed863fb41d65ee791cf8c0b20eb42a1fa46af" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-go" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13d476345220dbe600147dd444165c5791bf85ef53e28acbedd46112ee18431" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-graphql" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efedc4cac157161cc23a0adc4553a2cedc908e1cd754b6cd033a919bb81ce5d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-html" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261b708e5d92061ede329babaaa427b819329a9d427a1d710abb0f67bbef63ee" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf40bf599e0416c16c125c3cec10ee5ddc7d1bb8b0c60fa5c4de249ad34dc1b1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-jsdoc" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3862dfcb1038fc5e7812d7df14190afdeb7e1415288fd5f51f58395f8cb0faf" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-json" version = "0.24.8" @@ -6735,6 +6914,136 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" +[[package]] +name = "tree-sitter-make" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5998dc7cbcbdab19fae8aefef982bf2d6544513d8d2e69cc44aec4c63810104" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-md" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-proto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4360b434b5980fc397137ef29e1988619fef4159ac86fa7ac5777d459d3924" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-python" +version = "0.23.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-scala" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efde5e68b4736e9eac17bfa296c6f104a26bffab363b365eb898c40a63c15d2f" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-sequel" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d198ad3c319c02e43c21efa1ec796b837afcb96ffaef1a40c1978fbdcec7d17" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-swift" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe36052155b9dd69ca82b3b8f1b4ccfb2d867125ac1a4db1dd7331829242668c" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-toml-ng" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9adc2c898ae49730e857d75be403da3f92bb81d8e37a2f918a08dd10de5ebb1" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-yaml" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c223db85f05e34794f065454843b0668ebc15d240ada63e2b5939f43ce7c97" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-zig" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab11fc124851b0db4dd5e55983bbd9631192e93238389dcd44521715e5d53e28" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.16" diff --git a/src-gpui/Cargo.toml b/src-gpui/Cargo.toml index 4b4f0db9..92b99e85 100644 --- a/src-gpui/Cargo.toml +++ b/src-gpui/Cargo.toml @@ -16,7 +16,9 @@ infra = { path = "../src-tauri/crates/infra" } service = { path = "../src-tauri/crates/service" } gpui = "0.2.2" -gpui-component = "0.5.1" +gpui-component = { version = "0.5.1", features = ["tree-sitter-languages"] } +# Unify with tree-sitter-sequel (~1.2) and libsqlite3-sys (^1.2). +cc = "=1.2.67" diesel = { version = "2", features = ["sqlite"] } anyhow = "1" dirs = "6" diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 36e358b0..79395504 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -123,7 +123,13 @@ impl AppView { commit_body: input(window, cx, "", true), notes: input(window, cx, "", true), palette: input(window, cx, "", false), - file_editor: input(window, cx, "", true), + file_editor: cx.new(|cx| { + InputState::new(window, cx) + .multi_line(true) + .code_editor("text") + .line_number(true) + .soft_wrap(false) + }), file_search: input(window, cx, "Find", false), debug_search: input(window, cx, "", false), branch_search: input(window, cx, "", false), @@ -222,7 +228,16 @@ impl AppView { view.reload_projects(); view.restore_all_sessions(); if view.data.projects.is_empty() { - view.data.overlay.onboarding = true; + cx.spawn(async move |this, cx| { + Timer::after(Duration::from_millis(300)).await; + let _ = this.update(cx, |app, cx| { + if app.data.projects.is_empty() && app.data.overlay.dialog.is_none() { + app.data.overlay.onboarding = true; + cx.notify(); + } + }); + }) + .detach(); } else if let Some((project_id, profile_id)) = view.data.projects.first().and_then(|first| { first .profiles @@ -505,9 +520,9 @@ impl AppView { if let Some(ix) = ws.files.iter().position(|f| f.path == path) { ws.active = Some(UnifiedTab::File { index: ix }); if !ws.files[ix].preview { - self.inputs.file_editor.update(cx, |s, cx| { - s.set_value(ws.files[ix].draft.clone(), window, cx); - }); + let draft = ws.files[ix].draft.clone(); + let file_path = ws.files[ix].path.clone(); + self.bind_file_editor(&file_path, &draft, window, cx); } return; } @@ -527,12 +542,21 @@ impl AppView { index: ws.files.len() - 1, }); if !previewable { - self.inputs.file_editor.update(cx, |s, cx| { - s.set_value(content, window, cx); - }); + self.bind_file_editor(path, &content, window, cx); } } + pub fn bind_file_editor(&mut self, path: &str, content: &str, window: &mut Window, cx: &mut Context) { + let lang = backend::language_from_path(path); + let show_gutter = !backend::is_markdown(path); + self.inputs.file_editor.update(cx, |s, cx| { + s.set_highlighter(lang, cx); + s.set_line_number(show_gutter, window, cx); + s.set_soft_wrap(false, window, cx); + s.set_value(content.to_string(), window, cx); + }); + } + pub fn save_active_file(&mut self, window: &mut Window, cx: &mut Context) { let Some(profile_id) = self.data.current_profile.clone() else { return; diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index 31ff8c42..d8541cde 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -582,6 +582,40 @@ pub fn is_document_preview(kind: &str, path: &str) -> bool { matches!(kind, "pdf" | "office-pdf" | "office") || is_pdf(path) || is_office(path) } +pub fn language_from_path(path: &str) -> &'static str { + let ext = Path::new(path) + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + match ext.as_str() { + "rs" => "rust", + "ts" => "typescript", + "tsx" => "tsx", + "js" | "mjs" | "cjs" | "jsx" => "javascript", + "json" | "jsonc" => "json", + "py" => "python", + "go" => "go", + "toml" => "toml", + "yml" | "yaml" => "yaml", + "md" | "mdx" => "markdown", + "css" | "scss" => "css", + "html" | "htm" => "html", + "sh" | "bash" | "zsh" => "bash", + "c" | "h" => "c", + "cpp" | "cc" | "cxx" | "hpp" | "hh" => "cpp", + "java" => "java", + "rb" => "ruby", + "sql" => "sql", + "swift" => "swift", + "zig" => "zig", + "cs" => "csharp", + "diff" | "patch" => "diff", + "xml" => "html", + _ => "text", + } +} + pub fn file_name(path: &str) -> String { Path::new(path) .file_name() @@ -601,4 +635,12 @@ mod tests { assert!(is_document_preview("", "brief.docx")); assert!(!is_document_preview("", "readme.md")); } + + #[test] + fn language_from_path_maps_extensions() { + assert_eq!(language_from_path("src/main.rs"), "rust"); + assert_eq!(language_from_path("app.tsx"), "tsx"); + assert_eq!(language_from_path("notes.md"), "markdown"); + assert_eq!(language_from_path("unknown.xyz"), "text"); + } } diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index 816ef664..3032b8d7 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -1,4 +1,6 @@ -use gpui::{div, prelude::*, px, Context, MouseButton, Window}; +use std::time::Duration; + +use gpui::{div, prelude::*, px, Animation, AnimationExt, Context, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; @@ -225,10 +227,19 @@ fn node_view( }), ) .when(node.is_dir && node.expanded, |el| { - el.children( - node.children - .iter() - .map(|child| node_view(app, child, depth + 1, profile, window, cx)), + el.child( + v_flex() + .id(crate::ui::eid(format!("tree-children-{path}"))) + .children( + node.children + .iter() + .map(|child| node_view(app, child, depth + 1, profile, window, cx)), + ) + .with_animation( + crate::ui::eid(format!("tree-open-{path}")), + Animation::new(Duration::from_millis(18)), + |this, delta| this.opacity(delta).ml(px(12.0 * (1.0 - delta))), + ), ) }) .into_any_element() diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 8876ae4c..8e9c7c7c 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -141,11 +141,12 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) ), ) .child( - h_flex() + div() + .id("text-editor") .flex_1() .min_h_0() - .child(line_gutter(&file.draft, app.data.prefs.font_size, &theme)) - .child(div().flex_1().min_h_0().child(Input::new(&app.inputs.file_editor))), + .py(px(12.)) + .child(Input::new(&app.inputs.file_editor)), ) .into_any_element() } @@ -288,30 +289,6 @@ pub fn line_number_count(text: &str) -> usize { } } -fn line_gutter(text: &str, font_size: f32, theme: &gpui_component::Theme) -> impl IntoElement { - let count = line_number_count(text); - let line_h = px((font_size * 1.4).max(16.0)); - v_flex() - .id("line-gutter") - .w(px(48.)) - .h_full() - .py(px(12.)) - .px_1() - .bg(theme.muted) - .text_color(theme.muted_foreground) - .text_xs() - .font_family("monospace") - .children((1..=count).map(move |n| { - div() - .h(line_h) - .w_full() - .flex() - .justify_end() - .pr_1() - .child(format!("{n}")) - })) -} - fn checkerboard() -> impl IntoElement { let light = rgb(0x2a2e33); let dark = rgb(0x1b1f23); diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index 3b9e359e..e32f4a12 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -29,10 +29,14 @@ pub fn render_panel(app: &mut AppView, _window: &mut Window, cx: &mut Context impl IntoElement { .right_0() .h(px(28.)) .flex() - .child(win_btn("min", "–", false, |window| window.minimize_window())) - .child(win_btn("max", "□", false, |window| window.zoom_window())) - .child(win_btn("close", "×", true, |window| window.remove_window())) + .child(win_btn("min", "–", "Minimize", false, |window| { + window.minimize_window() + })) + .child(win_btn("max", "□", "Maximize", false, |window| window.zoom_window())) + .child(win_btn("close", "×", "Close", true, |window| window.remove_window())) } -fn win_btn(id: &'static str, label: &'static str, close: bool, on: impl Fn(&mut Window) + 'static) -> impl IntoElement { +fn win_btn( + id: &'static str, + label: &'static str, + tip: &'static str, + close: bool, + on: impl Fn(&mut Window) + 'static, +) -> impl IntoElement { div() .id(id) .h(px(28.)) @@ -171,6 +179,12 @@ fn win_btn(id: &'static str, label: &'static str, close: bool, on: impl Fn(&mut el.bg(gpui::hsla(0., 0., 0.5, 0.12)) } }) - .on_mouse_down(MouseButton::Left, move |_, window, _| on(window)) - .child(label) + .child( + Button::new(crate::ui::eid(format!("{id}-btn"))) + .ghost() + .xsmall() + .label(label) + .tooltip(tip.to_string()) + .on_click(move |_, window, _| on(window)), + ) } diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index 069f4a85..4114a95a 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -29,6 +29,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .id("app-sidebar-wrap") .relative() .h_full() + .child(onboarding_popover(app, width, cx)) .child( v_flex() .id("app-sidebar") @@ -433,44 +434,54 @@ fn project_sections(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !app.data.overlay.onboarding || has_any_projects(app) { + return div().id("onboarding-hidden").into_any_element(); + } + let theme = cx.theme().clone(); + let view = cx.entity(); + v_flex() + .id("onboarding-tour") + .absolute() + .top(px(148.)) + .left(px(sidebar_width + 8.)) + .w(px(260.)) + .p_3() + .gap_1() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.background) + .shadow_lg() + .child( + h_flex() + .justify_between() + .child(div().font_semibold().text_sm().child(app.t("onboardingTourTitle"))) + .child( + Button::new("onboarding-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.onboarding = false; + cx.notify(); + }); + } + }), + ), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("onboardingTourDesc")), + ) + .into_any_element() } fn has_any_projects(app: &AppView) -> bool { diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index d560219e..215fd6c6 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -105,23 +105,21 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> .justify_center() .gap_2() .child( - div() - .id("topbar-project-name") - .font_semibold() - .text_sm() + Button::new("topbar-project-name") + .ghost() + .small() + .label(name) + .tooltip(worktree.clone()) .on_click({ let view = view.clone(); - let worktree = worktree.clone(); move |_, _, cx| { view.update(cx, |app, _| { let _ = app .backend .reveal_path(app.data.current_profile.as_deref().unwrap_or(""), None); - let _ = worktree; }); } - }) - .child(name), + }), ) .child( h_flex() @@ -598,9 +596,8 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { if let Some(file) = app.data.current_ws().and_then(|w| w.files.get(ix)) { if !file.preview { let draft = file.draft.clone(); - app.inputs.file_editor.update(cx, |s, cx| { - s.set_value(draft, window, cx); - }); + let path = file.path.clone(); + app.bind_file_editor(&path, &draft, window, cx); } } cx.notify(); From 0f9344600dfc702d2c392fe3eb246d2b09351702 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 06:31:54 +0000 Subject: [PATCH 13/50] Add GPUI agent notifications, file-draft debounce, and chrome polish. Waiting agents now raise a system notification, file drafts sync after 400ms instead of on every frame, Windows maximize reflects restore state, and installing an update quits so the installer can replace the app. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 66 ++++++++++++++++++++++++++-------- src-gpui/src/platform.rs | 39 ++++++++++++++++++++ src-gpui/src/state.rs | 27 ++++++++++++++ src-gpui/src/ui/file_viewer.rs | 9 +++++ src-gpui/src/ui/notes.rs | 14 +++++++- src-gpui/src/ui/settings.rs | 5 ++- src-gpui/src/ui/shell.rs | 13 +++++-- src-gpui/src/ui/workspace.rs | 51 ++++++++++++++++++-------- 8 files changed, 190 insertions(+), 34 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 79395504..fc55f336 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -112,6 +112,7 @@ impl AppView { sidebar_error: None, notes_dirty_since: None, notes_bound_profile: None, + file_dirty_since: None, }; let inputs = Inputs { @@ -735,6 +736,7 @@ impl AppView { } self.drain_watch_events(); self.autosave_notes(cx); + self.sync_file_draft(cx); } fn notify_agent_waiting(&self) { @@ -745,6 +747,55 @@ impl AppView { if !sound.is_empty() { let _ = crate::platform::play_system_sound(&sound); } + if let Some(term) = self + .data + .workspaces + .values() + .flat_map(|ws| ws.terminals.iter()) + .find(|t| t.agent == AgentStatus::Waiting) + { + let title = if term.agent_kind == crate::state::AgentKind::Unknown { + self.t("agentWaitingNotificationTitleGeneric") + } else { + i18n::tf( + self.data.locale, + "agentWaitingNotificationTitle", + &[("agent", term.agent_kind.label())], + ) + }; + let body = i18n::tf( + self.data.locale, + "agentWaitingNotificationBody", + &[("tab", term.title.as_str())], + ); + crate::platform::send_notification(&title, &body); + } + } + + fn sync_file_draft(&mut self, cx: &mut Context) { + let Some(profile_id) = self.data.current_profile.clone() else { + return; + }; + let live = self.inputs.file_editor.read(cx).value().to_string(); + let Some(ws) = self.data.workspaces.get_mut(&profile_id) else { + return; + }; + let Some(file) = ws.active_file_mut() else { + self.data.file_dirty_since = None; + return; + }; + if file.preview || file.draft == live { + self.data.file_dirty_since = None; + return; + } + match self.data.file_dirty_since { + None => self.data.file_dirty_since = Some(std::time::Instant::now()), + Some(since) if since.elapsed() < Duration::from_millis(400) => {} + Some(_) => { + file.draft = live; + self.data.file_dirty_since = None; + } + } } fn drain_watch_events(&mut self) { @@ -2153,21 +2204,6 @@ impl gpui::Render for AppView { if self.data.overlay.palette_open { self.search_palette(cx); } - if let Some(ws) = self.data.current_ws() { - if let Some(UnifiedTab::File { index }) = ws.active { - if let Some(file) = ws.files.get(index) { - if !file.preview { - let live = self.inputs.file_editor.read(cx).value().to_string(); - if let Some(ws) = self.data.current_ws_mut() { - if let Some(file) = ws.active_file_mut() { - file.draft = live; - } - } - } - } - } - } - div() .id("app-root") .track_focus(&self.focus) diff --git a/src-gpui/src/platform.rs b/src-gpui/src/platform.rs index 575cd5ba..238324be 100644 --- a/src-gpui/src/platform.rs +++ b/src-gpui/src/platform.rs @@ -202,6 +202,40 @@ pub fn play_system_sound(name: &str) -> Result<(), String> { play_sound_name(name) } +pub fn send_notification(title: &str, body: &str) { + if title.is_empty() { + return; + } + #[cfg(target_os = "macos")] + { + let script = format!( + "display notification {} with title {}", + apple_script_string(body), + apple_script_string(title) + ); + let _ = std::process::Command::new("osascript").args(["-e", &script]).spawn(); + } + #[cfg(target_os = "linux")] + { + let _ = std::process::Command::new("notify-send").args([title, body]).spawn(); + } + #[cfg(target_os = "windows")] + { + let title = title.replace('\'', "''"); + let body = body.replace('\'', "''"); + let script = format!( + "[void][Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(4000, '{title}', '{body}', 'Info')" + ); + let _ = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", &script]) + .spawn(); + } +} + +fn apple_script_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + fn is_valid_sound_name(name: &str) -> bool { !name.is_empty() && !name.contains("..") && !name.contains('/') && !name.contains('\\') } @@ -498,6 +532,11 @@ mod tests { ); } + #[test] + fn apple_script_string_escapes_quotes() { + assert_eq!(apple_script_string(r#"say "hi""#), r#""say \"hi\"""#); + } + #[test] fn filter_fonts_respects_show_all() { let fonts = [ diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index a419e437..a01658f2 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -85,6 +85,32 @@ pub enum AgentKind { Other, } +impl AgentKind { + pub fn label(self) -> &'static str { + match self { + Self::Claude => "Claude", + Self::Codex => "Codex", + Self::Gemini => "Gemini", + Self::Cursor => "Cursor", + Self::Copilot => "Copilot", + Self::Amp => "Amp", + Self::Cline => "Cline", + Self::OpenCode => "OpenCode", + Self::Grok => "Grok", + Self::Kimi => "Kimi", + Self::Devin => "Devin", + Self::Droid => "Droid", + Self::Hermes => "Hermes", + Self::Kilo => "Kilo", + Self::Kiro => "Kiro", + Self::Pi => "Pi", + Self::Qoder => "Qoder", + Self::Agy => "Agy", + Self::Other | Self::Unknown => "Agent", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToastAction { OpenAbout, @@ -392,6 +418,7 @@ pub struct AppData { pub locale: Locale, pub notes_dirty_since: Option, pub notes_bound_profile: Option, + pub file_dirty_since: Option, } impl AppData { diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 8e9c7c7c..37abd6b9 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -43,6 +43,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .child(md_btn("md-h1", "H1", "# ", "", &view)) .child(md_btn("md-h2", "H2", "## ", "", &view)) .child(md_btn("md-h3", "H3", "### ", "", &view)) + .child(md_btn("md-p", "P", "", "\n\n", &view)) .child(md_btn("md-b", "B", "**", "**", &view)) .child(md_btn("md-i", "I", "*", "*", &view)) .child(md_btn("md-s", "S", "~~", "~~", &view)) @@ -53,6 +54,14 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .child(md_btn("md-q", ">", "> ", "", &view)) .child(md_btn("md-link", "[]", "[", "](url)", &view)) .child(md_btn("md-img", "img", "![", "](src)", &view)) + .child(md_btn( + "md-table", + "tbl", + "| A | B |\n| --- | --- |\n| | |\n", + "", + &view, + )) + .child(md_btn("md-hr", "—", "---\n", "", &view)) .child( div() .text_xs() diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs index 85bfba2b..c7f3528c 100644 --- a/src-gpui/src/ui/notes.rs +++ b/src-gpui/src/ui/notes.rs @@ -41,12 +41,24 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .border_b_1() .border_color(theme.border) .child(note_md("n-h1", "H1", "# ", "", &view)) + .child(note_md("n-h2", "H2", "## ", "", &view)) + .child(note_md("n-h3", "H3", "### ", "", &view)) .child(note_md("n-b", "B", "**", "**", &view)) .child(note_md("n-i", "I", "*", "*", &view)) + .child(note_md("n-s", "S", "~~", "~~", &view)) .child(note_md("n-code", "`", "`", "`", &view)) + .child(note_md("n-pre", "", "```\n", "\n```", &view)) .child(note_md("n-ul", "•", "- ", "", &view)) .child(note_md("n-q", ">", "> ", "", &view)) - .child(note_md("n-link", "[]", "[", "](url)", &view)), + .child(note_md("n-link", "[]", "[", "](url)", &view)) + .child(note_md( + "n-table", + "tbl", + "| A | B |\n| --- | --- |\n| | |\n", + "", + &view, + )) + .child(note_md("n-hr", "—", "---\n", "", &view)), ) .child( v_flex() diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 3832b4b6..38ad4e59 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -1026,7 +1026,10 @@ impl SettingsView { view.update(cx, |this, cx| { this.update_status = this.t("checkForUpdates"); match crate::updater::download_and_install(this.prefs.accept_beta) { - Ok(path) => this.update_status = path, + Ok(path) => { + this.update_status = path; + cx.quit(); + } Err(err) => { this.update_status = err; let _ = open::that(&url); diff --git a/src-gpui/src/ui/shell.rs b/src-gpui/src/ui/shell.rs index dc7456bd..4576b76f 100644 --- a/src-gpui/src/ui/shell.rs +++ b/src-gpui/src/ui/shell.rs @@ -77,7 +77,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) Route::Home => home_or_empty(app, window, cx), Route::Workspace => workspace::render(app, window, cx).into_any_element(), }) - .when(cfg!(target_os = "windows"), |el| el.child(window_controls())), + .when(cfg!(target_os = "windows"), |el| el.child(window_controls(window))), ) .child(dialogs::render(app, window, cx)) .child(palette::render(app, window, cx)) @@ -142,7 +142,8 @@ fn toasts(app: &AppView, cx: &mut Context) -> impl IntoElement { })) } -fn window_controls() -> impl IntoElement { +fn window_controls(window: &Window) -> impl IntoElement { + let maximized = window.is_maximized(); div() .id("win-controls") .absolute() @@ -153,7 +154,13 @@ fn window_controls() -> impl IntoElement { .child(win_btn("min", "–", "Minimize", false, |window| { window.minimize_window() })) - .child(win_btn("max", "□", "Maximize", false, |window| window.zoom_window())) + .child(win_btn( + "max", + if maximized { "❐" } else { "□" }, + if maximized { "Restore" } else { "Maximize" }, + false, + |window| window.zoom_window(), + )) .child(win_btn("close", "×", "Close", true, |window| window.remove_window())) } diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 215fd6c6..9cb3c3d8 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -519,6 +519,7 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { .ghost() .xsmall() .icon(IconName::Close) + .tooltip(format!("Close {title}", title = term.title)) .on_click({ let view = view.clone(); let id = id.clone(); @@ -573,6 +574,7 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { .ghost() .xsmall() .icon(IconName::Close) + .tooltip(format!("Close {title}", title = file.title)) .on_click({ let view = view.clone(); let path = path.clone(); @@ -617,20 +619,41 @@ fn new_terminal_control(app: &AppView, view: gpui::Entity) -> impl Into .map(|w| !w.config.terminal_templates.is_empty()) .unwrap_or(false); if has_templates { - Button::new("new-term-split") - .ghost() - .small() - .icon(IconName::Plus) - .label(app.t("newTerminal")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.context_menu = Some((crate::state::ContextMenu::NewTerminal, 200.0, 80.0)); - cx.notify(); - }); - } - }) + h_flex() + .gap_0() + .child( + Button::new("new-term") + .ghost() + .small() + .icon(IconName::Plus) + .label(app.t("newTerminal")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.create_terminal(&app.t("newTerminal"), "", Vec::new()); + cx.notify(); + }); + } + }), + ) + .child( + Button::new("new-term-split") + .ghost() + .small() + .icon(IconName::ChevronDown) + .tooltip("Choose template".to_string()) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.context_menu = + Some((crate::state::ContextMenu::NewTerminal, 200.0, 80.0)); + cx.notify(); + }); + } + }), + ) .into_any_element() } else { Button::new("new-term") From 440a3dc86d425980b1289a60bca201f75d202f10 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 06:35:14 +0000 Subject: [PATCH 14/50] Match GPUI terminals and git status to the inventory contract. Deleted tree files stay visible but no longer open, status badges map word statuses (untracked/deleted/ignored), Cmd+F / font-size shortcuts and paste work like the xterm shell, and archive previews use file icons. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 127 ++++++++++++++++++++++++++++++--- src-gpui/src/ui/file_tree.rs | 24 ++++++- src-gpui/src/ui/file_viewer.rs | 15 ++-- src-gpui/src/ui/git.rs | 1 + src-gpui/src/ui/settings.rs | 2 +- src-gpui/src/ui/terminal.rs | 38 ++++++++-- 6 files changed, 183 insertions(+), 24 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index fc55f336..05777d08 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -34,6 +34,15 @@ pub struct CommitChanges; #[derive(Clone, PartialEq, Default, Debug, Action)] #[action(namespace = twocode, no_json)] pub struct DismissOverlay; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct FindInTerminal; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct IncreaseFontSize; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct DecreaseFontSize; use crate::backend::{self, Backend}; use crate::i18n::{self, Locale}; use crate::prefs::{term_theme_by_name, Prefs, ThemePref}; @@ -953,7 +962,7 @@ impl AppView { } if is_dir { self.toggle_dir(&profile, path); - } else { + } else if !self.is_deleted_tree_path(&profile, path) { self.open_file(&profile, path, window, cx); } } @@ -1010,6 +1019,47 @@ impl AppView { } } + fn is_deleted_tree_path(&self, profile: &str, path: &str) -> bool { + self.data + .workspaces + .get(profile) + .and_then(|w| w.git_files.iter().find(|(p, _)| p == path || p.ends_with(path))) + .is_some_and(|(_, status)| git_status_kind(status) == GitStatusKind::Deleted) + } + + pub fn open_find(&mut self, window: &mut Window, cx: &mut Context) { + let file_tab = self + .data + .current_ws() + .and_then(|w| w.active.as_ref()) + .is_some_and(|tab| matches!(tab, UnifiedTab::File { .. })); + if file_tab { + self.inputs.file_search.update(cx, |input, cx| { + input.focus(window, cx); + }); + return; + } + if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.search_open = true; + } + self.inputs.term_search.update(cx, |input, cx| { + input.focus(window, cx); + }); + } + + pub fn bump_font_size(&mut self, delta: f32) { + self.data.prefs.font_size = (self.data.prefs.font_size + delta).clamp(10.0, 20.0); + self.persist_prefs(); + } + + pub fn paste_to_pty(&mut self, cx: &mut Context) { + if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { + if !text.is_empty() { + self.write_to_active_pty(text.as_bytes()); + } + } + } + pub fn cycle_term_search(&mut self, cx: &mut Context, next: bool) { let query = self.inputs.term_search.read(cx).value().to_string(); if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { @@ -1905,6 +1955,13 @@ impl AppView { KeyBinding::new("cmd-enter", CommitChanges, None), KeyBinding::new("ctrl-enter", CommitChanges, None), KeyBinding::new("escape", DismissOverlay, None), + KeyBinding::new("cmd-f", FindInTerminal, None), + KeyBinding::new("ctrl-shift-f", FindInTerminal, None), + KeyBinding::new("cmd-=", IncreaseFontSize, None), + KeyBinding::new("cmd-+", IncreaseFontSize, None), + KeyBinding::new("ctrl-=", IncreaseFontSize, None), + KeyBinding::new("cmd--", DecreaseFontSize, None), + KeyBinding::new("ctrl--", DecreaseFontSize, None), ]); } @@ -2141,16 +2198,40 @@ pub fn parse_diff_files(diff: &str) -> Vec { files } -pub fn file_status_badge(status: &str) -> &'static str { - let s = status.to_ascii_uppercase(); - if s.contains('A') || s.contains('?') { - "A" - } else if s.contains('D') { - "D" - } else if s.contains('R') { - "R" +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitStatusKind { + Added, + Untracked, + Deleted, + Renamed, + Ignored, + Modified, +} + +pub fn git_status_kind(status: &str) -> GitStatusKind { + let s = status.trim().to_ascii_lowercase(); + if s.contains("untrack") || s.contains('?') { + GitStatusKind::Untracked + } else if s.contains("added") || s == "a" || s.contains("add") { + GitStatusKind::Added + } else if s.contains("delet") || s == "d" { + GitStatusKind::Deleted + } else if s.contains("renam") || s == "r" { + GitStatusKind::Renamed + } else if s.contains("ignor") { + GitStatusKind::Ignored } else { - "M" + GitStatusKind::Modified + } +} + +pub fn file_status_badge(status: &str) -> &'static str { + match git_status_kind(status) { + GitStatusKind::Added | GitStatusKind::Untracked => "A", + GitStatusKind::Deleted => "D", + GitStatusKind::Renamed => "R", + GitStatusKind::Ignored => "I", + GitStatusKind::Modified => "M", } } @@ -2277,6 +2358,18 @@ impl gpui::Render for AppView { cx.notify(); } })) + .on_action(cx.listener(|this, _: &FindInTerminal, window, cx| { + this.open_find(window, cx); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &IncreaseFontSize, _, cx| { + this.bump_font_size(1.0); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &DecreaseFontSize, _, cx| { + this.bump_font_size(-1.0); + cx.notify(); + })) .child(ui::shell::render(self, window, cx)) } } @@ -2296,7 +2389,7 @@ pub fn suggested_project_name(folder: &str, current_name: &str) -> String { #[cfg(test)] mod tests { - use super::{suggested_project_name, unique_tree_name}; + use super::{file_status_badge, git_status_kind, suggested_project_name, unique_tree_name, GitStatusKind}; #[test] fn unique_tree_name_adds_numbers() { @@ -2314,4 +2407,16 @@ mod tests { assert_eq!(suggested_project_name("/tmp/my-app", " "), "my-app"); assert_eq!(suggested_project_name("/tmp/my-app", "Custom"), "Custom"); } + + #[test] + fn file_status_badge_maps_word_and_letter_status() { + assert_eq!(file_status_badge("untracked"), "A"); + assert_eq!(file_status_badge("added"), "A"); + assert_eq!(file_status_badge("deleted"), "D"); + assert_eq!(file_status_badge("renamed"), "R"); + assert_eq!(file_status_badge("ignored"), "I"); + assert_eq!(file_status_badge("modified"), "M"); + assert_eq!(git_status_kind("deleted"), GitStatusKind::Deleted); + assert_eq!(git_status_kind("??"), GitStatusKind::Untracked); + } } diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index 3032b8d7..cdb215aa 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -24,7 +24,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .size_full() .px_1() .py_1() - .text_sm() + .text_size(px(13.)) .on_mouse_down(MouseButton::Right, { let view = view.clone(); move |ev, _, cx| { @@ -215,13 +215,31 @@ fn node_view( ) .into_any_element() } else { - div().flex_1().child(node.name.clone()).into_any_element() + div() + .flex_1() + .when( + status.as_ref().is_some_and(|st| { + crate::app::git_status_kind(st) == crate::app::GitStatusKind::Deleted + }), + |el| el.line_through().text_color(theme.muted_foreground), + ) + .child(node.name.clone()) + .into_any_element() }) .when_some(status.filter(|_| !renaming), |el, st| { + let kind = crate::app::git_status_kind(&st); el.child( div() .text_xs() - .text_color(if st.contains('D') { theme.danger } else { theme.success }) + .text_color(match kind { + crate::app::GitStatusKind::Added | crate::app::GitStatusKind::Untracked => { + theme.success + } + crate::app::GitStatusKind::Deleted => theme.danger, + crate::app::GitStatusKind::Renamed => theme.warning, + crate::app::GitStatusKind::Ignored => theme.muted_foreground, + crate::app::GitStatusKind::Modified => theme.warning, + }) .child(crate::app::file_status_badge(&st)), ) }), diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 37abd6b9..031224b4 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -4,7 +4,7 @@ use gpui::{div, img, prelude::*, px, rgb, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; use gpui_component::text::TextView; -use gpui_component::{h_flex, v_flex, ActiveTheme, Sizable, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, Sizable, StyledExt}; use crate::app::AppView; use crate::backend; @@ -260,10 +260,17 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> .child(format!("{files} files / {folders} folders")), ) .children(file.archive_entries.iter().map(|(path, kind)| { + let is_dir = kind == "dir"; + let depth = path.matches('/').count(); h_flex() - .gap_2() - .child(div().text_xs().text_color(theme.muted_foreground).child(kind.clone())) - .child(div().text_sm().child(path.clone())) + .gap_1() + .pl(px(4. + 12. * depth as f32)) + .child( + Icon::new(crate::ui::file_icons::file_icon(path, is_dir, false)) + .w(px(13.)) + .text_color(gpui::rgb(crate::ui::file_icons::file_icon_color(path, is_dir))), + ) + .child(div().text_size(px(13.)).child(path.clone())) })) .into_any_element(); } diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index e32f4a12..d1889796 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -162,6 +162,7 @@ fn file_row( .text_color(match badge { "A" => theme.success, "D" => theme.danger, + "R" | "M" => theme.warning, _ => theme.muted_foreground, }) .child(badge), diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 38ad4e59..c24aceba 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -520,7 +520,7 @@ impl SettingsView { let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { - this.prefs.font_size = (this.prefs.font_size + 1.0).min(22.0); + this.prefs.font_size = (this.prefs.font_size + 1.0).min(20.0); this.persist(cx); cx.notify(); }); diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index 67cd24a3..0f603884 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -57,13 +57,20 @@ pub fn render(app: &mut AppView, index: usize, _window: &mut Window, cx: &mut Co || ev.keystroke.modifiers.platform && ev.keystroke.key == "f" { view.update(cx, |app, cx| { - if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { - term.search_open = true; - } + app.open_find(window, cx); cx.notify(); }); return; } + if (ev.keystroke.modifiers.platform || ev.keystroke.modifiers.control) + && ev.keystroke.key == "v" + && !ev.keystroke.modifiers.alt + { + view.update(cx, |app, cx| { + app.paste_to_pty(cx); + }); + return; + } let handled = view.update(cx, |app, cx| { let handled = app.handle_overlay_key( ev.keystroke.key.as_str(), @@ -109,8 +116,10 @@ pub fn render(app: &mut AppView, index: usize, _window: &mut Window, cx: &mut Co div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(if hits.is_empty() { - "0/0".to_string() + .child(if search_query.is_empty() { + String::new() + } else if hits.is_empty() { + app.t("terminalSearchNoResults") } else { format!("{}/{}", hit_ix + 1, hits.len()) }), @@ -152,6 +161,7 @@ pub fn render(app: &mut AppView, index: usize, _window: &mut Window, cx: &mut Co .ghost() .xsmall() .icon(IconName::Close) + .tooltip(app.t("terminalSearchClose")) .on_click({ let view = view.clone(); move |_, _, cx| { @@ -297,6 +307,24 @@ fn brighten(color: u32) -> u32 { fn key_to_bytes(ev: &KeyDownEvent) -> Option> { let key = ev.keystroke.key.as_str(); + let mods = &ev.keystroke.modifiers; + if mods.shift && !mods.control && !mods.platform && !mods.alt && key == "enter" { + return Some(b"\n".to_vec()); + } + if mods.platform && !mods.control && !mods.alt && !mods.shift { + match key { + "left" => return Some(b"\x1b[H".to_vec()), + "right" => return Some(b"\x1b[F".to_vec()), + _ => {} + } + } + if mods.alt && !mods.control && !mods.platform && !mods.shift { + match key { + "left" => return Some(b"\x1bb".to_vec()), + "right" => return Some(b"\x1bf".to_vec()), + _ => {} + } + } if ev.keystroke.modifiers.control { return match key { "c" => Some(vec![0x03]), From 8eb4f17ddbfeec4af3d44c3004ffdb8b8cd44f95 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 06:36:36 +0000 Subject: [PATCH 15/50] Complete file-tree root menus and notes toolbar labels. Root context menu now copies relative/absolute paths, Open skips deleted git files, and the notes toolbar exposes numbered lists plus i18n tooltips. Co-authored-by: AkaraChen --- src-gpui/src/ui/dialogs.rs | 15 +++++++++++++- src-gpui/src/ui/notes.rs | 42 +++++++++++++++++++++++++++----------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index 46742b34..638cde9f 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -798,6 +798,8 @@ fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec<(String, bool, MenuActio false, MenuAction::Reveal, ), + (app.t("fileTreeContextMenuCopyRelativePath"), false, MenuAction::CopyRel), + (app.t("fileTreeContextMenuCopyAbsolutePath"), false, MenuAction::CopyAbs), ], ContextMenu::NewTerminal => { let mut items = vec![(app.t("newTerminal"), false, MenuAction::NewTerm)]; @@ -864,7 +866,16 @@ fn run_menu(app: &mut AppView, menu: &ContextMenu, action: MenuAction, window: & } (ContextMenu::File { path }, MenuAction::Open) => { if let Some(pid) = app.data.current_profile.clone() { - app.open_file(&pid, path, window, cx); + if crate::app::git_status_kind( + &app.data + .workspaces + .get(&pid) + .and_then(|w| w.git_files.iter().find(|(p, _)| p == path).map(|(_, s)| s.clone())) + .unwrap_or_default(), + ) != crate::app::GitStatusKind::Deleted + { + app.open_file(&pid, path, window, cx); + } } } (ContextMenu::File { path }, MenuAction::OpenDefault) => app.open_external(path), @@ -882,6 +893,8 @@ fn run_menu(app: &mut AppView, menu: &ContextMenu, action: MenuAction, window: & (_, MenuAction::NewFolder) => app.create_path(true, window, cx), (ContextMenu::File { path }, MenuAction::CopyRel) => app.copy_path(path, false, cx), (ContextMenu::File { path }, MenuAction::CopyAbs) => app.copy_path(path, true, cx), + (ContextMenu::TreeBlank, MenuAction::CopyRel) => app.copy_path(".", false, cx), + (ContextMenu::TreeBlank, MenuAction::CopyAbs) => app.copy_path(".", true, cx), (ContextMenu::File { path }, MenuAction::DeletePath) => app.delete_tree_path(path), (_, MenuAction::NewTerm) => app.create_terminal(&app.t("newTerminal"), "", Vec::new()), (_, MenuAction::Template(i)) => { diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs index c7f3528c..3c402977 100644 --- a/src-gpui/src/ui/notes.rs +++ b/src-gpui/src/ui/notes.rs @@ -40,25 +40,41 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .gap_1() .border_b_1() .border_color(theme.border) - .child(note_md("n-h1", "H1", "# ", "", &view)) - .child(note_md("n-h2", "H2", "## ", "", &view)) - .child(note_md("n-h3", "H3", "### ", "", &view)) - .child(note_md("n-b", "B", "**", "**", &view)) - .child(note_md("n-i", "I", "*", "*", &view)) - .child(note_md("n-s", "S", "~~", "~~", &view)) - .child(note_md("n-code", "`", "`", "`", &view)) - .child(note_md("n-pre", "", "```\n", "\n```", &view)) - .child(note_md("n-ul", "•", "- ", "", &view)) - .child(note_md("n-q", ">", "> ", "", &view)) - .child(note_md("n-link", "[]", "[", "](url)", &view)) + .child(note_md("n-h1", "H1", "# ", "", &view, &app.t("notesFormatHeading1"))) + .child(note_md("n-h2", "H2", "## ", "", &view, &app.t("notesFormatHeading2"))) + .child(note_md("n-h3", "H3", "### ", "", &view, &app.t("notesFormatHeading3"))) + .child(note_md("n-b", "B", "**", "**", &view, &app.t("notesFormatBold"))) + .child(note_md("n-i", "I", "*", "*", &view, &app.t("notesFormatItalic"))) + .child(note_md("n-s", "S", "~~", "~~", &view, &app.t("notesFormatStrike"))) + .child(note_md("n-code", "`", "`", "`", &view, &app.t("notesFormatCode"))) + .child(note_md( + "n-pre", + "", + "```\n", + "\n```", + &view, + &app.t("notesFormatCodeBlock"), + )) + .child(note_md("n-ul", "•", "- ", "", &view, &app.t("notesFormatBulletList"))) + .child(note_md( + "n-ol", + "1.", + "1. ", + "", + &view, + &app.t("notesFormatOrderedList"), + )) + .child(note_md("n-q", ">", "> ", "", &view, &app.t("notesFormatQuote"))) + .child(note_md("n-link", "[]", "[", "](url)", &view, &app.t("notesFormatLink"))) .child(note_md( "n-table", "tbl", "| A | B |\n| --- | --- |\n| | |\n", "", &view, + &app.t("notesTableMenu"), )) - .child(note_md("n-hr", "—", "---\n", "", &view)), + .child(note_md("n-hr", "—", "---\n", "", &view, &app.t("notesInsertDivider"))), ) .child( v_flex() @@ -90,12 +106,14 @@ fn note_md( prefix: &'static str, suffix: &'static str, view: &gpui::Entity, + tooltip: &str, ) -> impl IntoElement { let view = view.clone(); Button::new(id) .ghost() .xsmall() .label(label) + .tooltip(tooltip.to_string()) .on_click(move |_, window, cx| { view.update(cx, |app, cx| { let mut text = app.inputs.notes.read(cx).value().to_string(); From 34f26f9440a562b7aa054ea031b8ccba26e2d801 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 06:39:50 +0000 Subject: [PATCH 16/50] Keep the profile sidebar mounted and match terminal preview chrome. Closing Files/Git/Notes no longer unmounts the tree. Markdown tools insert at the caret, settings show the inventory terminal preview sample, search hits use the xterm colors, and the file tree shows a sticky folder crumb. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 43 ++++++++++++++++++++++- src-gpui/src/ui/file_tree.rs | 30 ++++++++++++++++ src-gpui/src/ui/file_viewer.rs | 64 +++++++++++++++++++++------------- src-gpui/src/ui/notes.rs | 10 +----- src-gpui/src/ui/settings.rs | 37 +++++++++++++++++--- src-gpui/src/ui/terminal.rs | 6 ++-- src-gpui/src/ui/workspace.rs | 28 ++++++++------- 7 files changed, 164 insertions(+), 54 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 05777d08..f680aa1e 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -2387,9 +2387,43 @@ pub fn suggested_project_name(folder: &str, current_name: &str) -> String { } } +pub fn offset_line_col(text: &str, offset: usize) -> (u32, u32) { + let mut cursor = offset.min(text.len()); + while cursor > 0 && !text.is_char_boundary(cursor) { + cursor -= 1; + } + let before = &text[..cursor]; + let line = before.matches('\n').count() as u32; + let character = before.rsplit('\n').next().unwrap_or("").chars().count() as u32; + (line, character) +} + +pub fn wrap_markup( + input: &Entity, + prefix: &str, + suffix: &str, + window: &mut Window, + cx: &mut Context, +) { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let mut cursor = state.cursor().min(text.len()); + while cursor > 0 && !text.is_char_boundary(cursor) { + cursor -= 1; + } + let new_text = format!("{}{prefix}{suffix}{}", &text[..cursor], &text[cursor..]); + let caret = cursor + prefix.len(); + state.set_value(new_text.clone(), window, cx); + let (line, character) = offset_line_col(&new_text, caret); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); +} + #[cfg(test)] mod tests { - use super::{file_status_badge, git_status_kind, suggested_project_name, unique_tree_name, GitStatusKind}; + use super::{ + file_status_badge, git_status_kind, offset_line_col, suggested_project_name, unique_tree_name, GitStatusKind, + }; #[test] fn unique_tree_name_adds_numbers() { @@ -2419,4 +2453,11 @@ mod tests { assert_eq!(git_status_kind("deleted"), GitStatusKind::Deleted); assert_eq!(git_status_kind("??"), GitStatusKind::Untracked); } + + #[test] + fn offset_line_col_tracks_lines() { + assert_eq!(offset_line_col("abc", 2), (0, 2)); + assert_eq!(offset_line_col("ab\ncd", 4), (1, 1)); + assert_eq!(offset_line_col("", 0), (0, 0)); + } } diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index cdb215aa..eabbe456 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -50,6 +50,21 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .when_some(err, |el, err| { el.child(div().text_xs().text_color(theme.danger).opacity(0.8).child(err)) }) + .when_some(sticky_folder(app), |el, crumb| { + el.child( + div() + .id("tree-sticky-folder") + .w_full() + .px_1() + .py_0p5() + .mb_1() + .rounded_md() + .bg(theme.muted) + .text_xs() + .font_semibold() + .child(crumb), + ) + }) .child(match root { None => div() .p_3() @@ -262,3 +277,18 @@ fn node_view( }) .into_any_element() } + +fn sticky_folder(app: &AppView) -> Option { + let ws = app.data.current_ws()?; + if let Some(selected) = ws.tree_selected.iter().next() { + let parent = selected.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(selected); + if !parent.is_empty() { + return Some(parent.to_string()); + } + } + ws.tree + .values() + .filter(|node| node.is_dir && node.expanded && !node.path.is_empty()) + .max_by_key(|node| node.path.matches('/').count()) + .map(|node| node.path.clone()) +} diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 031224b4..62510923 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -40,28 +40,50 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .gap_1() .border_b_1() .border_color(theme.border) - .child(md_btn("md-h1", "H1", "# ", "", &view)) - .child(md_btn("md-h2", "H2", "## ", "", &view)) - .child(md_btn("md-h3", "H3", "### ", "", &view)) - .child(md_btn("md-p", "P", "", "\n\n", &view)) - .child(md_btn("md-b", "B", "**", "**", &view)) - .child(md_btn("md-i", "I", "*", "*", &view)) - .child(md_btn("md-s", "S", "~~", "~~", &view)) - .child(md_btn("md-code", "`", "`", "`", &view)) - .child(md_btn("md-pre", "", "```\n", "\n```", &view)) - .child(md_btn("md-ul", "•", "- ", "", &view)) - .child(md_btn("md-ol", "1.", "1. ", "", &view)) - .child(md_btn("md-q", ">", "> ", "", &view)) - .child(md_btn("md-link", "[]", "[", "](url)", &view)) - .child(md_btn("md-img", "img", "![", "](src)", &view)) + .child(md_btn("md-h1", "H1", "# ", "", &view, &app.t("notesFormatHeading1"))) + .child(md_btn("md-h2", "H2", "## ", "", &view, &app.t("notesFormatHeading2"))) + .child(md_btn("md-h3", "H3", "### ", "", &view, &app.t("notesFormatHeading3"))) + .child(md_btn("md-p", "P", "", "\n\n", &view, &app.t("notesFormatParagraph"))) + .child(md_btn("md-b", "B", "**", "**", &view, &app.t("notesFormatBold"))) + .child(md_btn("md-i", "I", "*", "*", &view, &app.t("notesFormatItalic"))) + .child(md_btn("md-s", "S", "~~", "~~", &view, &app.t("notesFormatStrike"))) + .child(md_btn("md-code", "`", "`", "`", &view, &app.t("notesFormatCode"))) + .child(md_btn( + "md-pre", + "", + "```\n", + "\n```", + &view, + &app.t("notesFormatCodeBlock"), + )) + .child(md_btn("md-ul", "•", "- ", "", &view, &app.t("notesFormatBulletList"))) + .child(md_btn( + "md-ol", + "1.", + "1. ", + "", + &view, + &app.t("notesFormatOrderedList"), + )) + .child(md_btn("md-q", ">", "> ", "", &view, &app.t("notesFormatQuote"))) + .child(md_btn("md-link", "[]", "[", "](url)", &view, &app.t("notesFormatLink"))) + .child(md_btn( + "md-img", + "img", + "![", + "](src)", + &view, + &app.t("notesFormatLink"), + )) .child(md_btn( "md-table", "tbl", "| A | B |\n| --- | --- |\n| | |\n", "", &view, + &app.t("notesTableMenu"), )) - .child(md_btn("md-hr", "—", "---\n", "", &view)) + .child(md_btn("md-hr", "—", "---\n", "", &view, &app.t("notesInsertDivider"))) .child( div() .text_xs() @@ -384,23 +406,17 @@ fn md_btn( prefix: &'static str, suffix: &'static str, view: &gpui::Entity, + tooltip: &str, ) -> impl IntoElement { let view = view.clone(); Button::new(id) .ghost() .xsmall() .label(label) + .tooltip(tooltip.to_string()) .on_click(move |_, window, cx| { view.update(cx, |app, cx| { - let mut text = app.inputs.file_editor.read(cx).value().to_string(); - if !text.is_empty() && !text.ends_with('\n') && (prefix.ends_with(' ') || prefix.ends_with('\n')) { - text.push('\n'); - } - text.push_str(prefix); - text.push_str(suffix); - app.inputs.file_editor.update(cx, |s, cx| { - s.set_value(text, window, cx); - }); + crate::app::wrap_markup(&app.inputs.file_editor, prefix, suffix, window, cx); }); }) } diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs index 3c402977..bbc0b815 100644 --- a/src-gpui/src/ui/notes.rs +++ b/src-gpui/src/ui/notes.rs @@ -116,15 +116,7 @@ fn note_md( .tooltip(tooltip.to_string()) .on_click(move |_, window, cx| { view.update(cx, |app, cx| { - let mut text = app.inputs.notes.read(cx).value().to_string(); - if !text.is_empty() && !text.ends_with('\n') && prefix.ends_with(' ') { - text.push('\n'); - } - text.push_str(prefix); - text.push_str(suffix); - app.inputs.notes.update(cx, |s, cx| { - s.set_value(text, window, cx); - }); + crate::app::wrap_markup(&app.inputs.notes, prefix, suffix, window, cx); }); }) } diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index c24aceba..4e149a56 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -531,16 +531,36 @@ impl SettingsView { ) .child( v_flex() + .id("terminal-preview") .flex_1() .h(px(220.)) .rounded_lg() - .p_3() + .px(px(16.)) + .py(px(12.)) + .gap_0() .bg(gpui::rgb(theme.bg)) .text_color(gpui::rgb(theme.fg)) - .font_family("monospace") - .child("$ echo 2code") - .child("2code") - .child(format!("theme: {}", theme.name)), + .font_family(self.prefs.font_family.clone()) + .text_size(px(self.prefs.font_size)) + .border_1() + .border_color(cx.theme().border) + .child(preview_line(0x3fb950, "$", theme.fg, "whoami")) + .child(div().child("2code")) + .child(preview_line(0x3fb950, "$", theme.fg, "ls")) + .child(div().child("src-gpui docs messages")) + .child(preview_line(0x3fb950, "$", theme.fg, "echo \"Hello, 2code!\"")) + .child(div().child("Hello, 2code!")) + .child( + h_flex() + .gap_1() + .child(div().text_color(gpui::rgb(0x3fb950)).child("$")) + .child( + div() + .w(px(8.)) + .h(px(self.prefs.font_size)) + .bg(gpui::rgb(theme.cursor)), + ), + ), ) } @@ -1127,3 +1147,10 @@ fn link_btn(id: &'static str, label: String, url: &'static str) -> impl IntoElem let _ = open::that(url); }) } + +fn preview_line(prompt_color: u32, prompt: &str, fg: u32, cmd: &str) -> impl IntoElement { + h_flex() + .gap_1() + .child(div().text_color(gpui::rgb(prompt_color)).child(prompt.to_string())) + .child(div().text_color(gpui::rgb(fg)).child(cmd.to_string())) +} diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index 0f603884..f4a89cd5 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -248,11 +248,11 @@ fn render_grid(term: &crate::state::TermSession, theme: &TermTheme, query: &str, bg = if active == Some((row, col as usize, query_len)) || active.is_some_and(|(r, c, len)| r == row && (col as usize) >= c && (col as usize) < c + len) { - 0xe3b341 + 0x1f6feb } else { - 0x3b6ea8 + 0x5f4b16 }; - fg = 0x0d1117; + fg = 0xffffff; } if let Some((text, last_fg, last_bg, last_hi)) = spans.last_mut() { if *last_fg == fg && *last_bg == bg && *last_hi == highlight { diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 9cb3c3d8..deb7af3f 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -371,10 +371,9 @@ fn profile_sidebar(app: &mut AppView, window: &mut Window, cx: &mut Context Date: Thu, 3 Sep 2026 06:43:13 +0000 Subject: [PATCH 17/50] Keep every PTY grid mounted across profile switches. Terminals from all workspaces stay in one pty-layer with stable session ids, matching the old TerminalLayer contract. PDF/Office previews rasterize page one when pdftoppm is available, and toasts dismiss on click. Co-authored-by: AkaraChen --- src-gpui/src/backend.rs | 37 +++++++++++++++++++++++ src-gpui/src/state.rs | 41 +++++++++++++++++++++++-- src-gpui/src/ui/file_viewer.rs | 20 +++++++++++-- src-gpui/src/ui/shell.rs | 10 +++++++ src-gpui/src/ui/terminal.rs | 55 +++++++++++++++++++++------------- src-gpui/src/ui/workspace.rs | 54 ++++++++++++++++++++++++--------- 6 files changed, 179 insertions(+), 38 deletions(-) diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index d8541cde..b1efd6e4 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -582,6 +582,38 @@ pub fn is_document_preview(kind: &str, path: &str) -> bool { matches!(kind, "pdf" | "office-pdf" | "office") || is_pdf(path) || is_office(path) } +pub fn rasterize_pdf_preview(pdf: &str) -> Option { + let pdf = Path::new(pdf); + if !pdf.is_file() { + return None; + } + let dest = pdf.with_file_name(format!("{}.preview-page1.png", pdf.file_stem()?.to_string_lossy())); + if dest.is_file() { + let png_mtime = dest.metadata().ok().and_then(|m| m.modified().ok()); + let pdf_mtime = pdf.metadata().ok().and_then(|m| m.modified().ok()); + if match (png_mtime, pdf_mtime) { + (Some(png), Some(src)) => png >= src, + _ => true, + } { + return Some(dest); + } + } + let prefix = dest.with_extension(""); + for bin in ["pdftoppm", "pdftocairo"] { + let ok = std::process::Command::new(bin) + .args(["-png", "-f", "1", "-l", "1", "-singlefile"]) + .arg(pdf) + .arg(&prefix) + .status() + .map(|status| status.success()) + .unwrap_or(false); + if ok && dest.is_file() { + return Some(dest); + } + } + None +} + pub fn language_from_path(path: &str) -> &'static str { let ext = Path::new(path) .extension() @@ -643,4 +675,9 @@ mod tests { assert_eq!(language_from_path("notes.md"), "markdown"); assert_eq!(language_from_path("unknown.xyz"), "text"); } + + #[test] + fn rasterize_missing_pdf_is_none() { + assert!(rasterize_pdf_preview("/no/such/file.pdf").is_none()); + } } diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index a01658f2..daa371ae 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -160,6 +160,20 @@ pub struct Toast { pub created: Instant, } +impl Toast { + pub fn ttl_secs(&self) -> u64 { + if self.action.is_some() { + 12 + } else { + 5 + } + } + + pub fn alive(&self) -> bool { + self.created.elapsed().as_secs() < self.ttl_secs() + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToastKind { Success, @@ -468,8 +482,7 @@ impl AppData { } pub fn expire_toasts(&mut self) { - self.toasts - .retain(|t| t.created.elapsed().as_secs() < if t.action.is_some() { 12 } else { 5 }); + self.toasts.retain(Toast::alive); } } @@ -533,4 +546,28 @@ mod tests { vec![SidebarNavItem::Home] ); } + + #[test] + fn update_toasts_live_twelve_seconds() { + let update = Toast { + id: 1, + kind: ToastKind::Info, + title: "update".into(), + body: String::new(), + action: Some(ToastAction::OpenAbout), + created: Instant::now(), + }; + let plain = Toast { + id: 2, + kind: ToastKind::Success, + title: "ok".into(), + body: String::new(), + action: None, + created: Instant::now(), + }; + assert_eq!(update.ttl_secs(), 12); + assert_eq!(plain.ttl_secs(), 5); + assert!(update.alive()); + assert!(plain.alive()); + } } diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 62510923..51ac7a66 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -246,7 +246,23 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> .into_any_element(); } if backend::is_document_preview(&file.preview_kind, &file.path) { - let path = file.path.clone(); + let path = if file.preview_path.is_empty() { + file.path.clone() + } else { + file.preview_path.clone() + }; + if let Some(png) = backend::rasterize_pdf_preview(&path) { + return div() + .id("document-preview") + .size_full() + .bg(rgb(0xffffff)) + .flex() + .items_center() + .justify_center() + .child(img(png).id("pdf-preview-img").max_w_full().max_h_full()) + .into_any_element(); + } + let open_path = path.clone(); return v_flex() .id("document-preview") .size_full() @@ -263,7 +279,7 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> }, )) .child(Button::new("doc-open").small().label("Open").on_click(move |_, _, _| { - let _ = open::that(&path); + let _ = open::that(&open_path); })) .into_any_element(); } diff --git a/src-gpui/src/ui/shell.rs b/src-gpui/src/ui/shell.rs index 4576b76f..1ad50a2e 100644 --- a/src-gpui/src/ui/shell.rs +++ b/src-gpui/src/ui/shell.rs @@ -115,6 +115,16 @@ fn toasts(app: &AppView, cx: &mut Context) -> impl IntoElement { .border_color(theme.border) .bg(bg) .shadow_md() + .on_click({ + let view = view.clone(); + let id = toast.id; + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.toasts.retain(|t| t.id != id); + cx.notify(); + }); + } + }) .child(div().text_sm().font_semibold().child(toast.title.clone())) .when(!toast.body.is_empty(), |el| { el.child( diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index f4a89cd5..98e3828a 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -6,10 +6,17 @@ use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; use crate::app::AppView; use crate::prefs::TermTheme; -pub fn render(app: &mut AppView, index: usize, _window: &mut Window, cx: &mut Context) -> impl IntoElement { +pub fn render( + app: &mut AppView, + profile_id: &str, + index: usize, + interactive: bool, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { let theme = app.current_term_theme(); let view = cx.entity(); - let Some(term) = app.data.current_ws().and_then(|w| w.terminals.get(index)) else { + let Some(term) = app.data.workspaces.get(profile_id).and_then(|w| w.terminals.get(index)) else { return div().id("term-missing").into_any_element(); }; let search_open = term.search_open; @@ -32,25 +39,30 @@ pub fn render(app: &mut AppView, index: usize, _window: &mut Window, cx: &mut Co .text_color(rgb(theme.fg)) .font_family(app.data.prefs.font_family.clone()) .text_size(px(app.data.prefs.font_size)) - .on_mouse_up(gpui::MouseButton::Left, { - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - if app.data.overlay.drag_file.is_some() { - app.drop_file_on_terminal(); - cx.notify(); - } - }); - } + .when(!interactive, |el| el.invisible().absolute().inset_0()) + .when(interactive, |el| { + el.on_mouse_up(gpui::MouseButton::Left, { + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.drag_file.is_some() { + app.drop_file_on_terminal(); + cx.notify(); + } + }); + } + }) }) - .child( - div() + .child({ + let body = div() .id(crate::ui::eid(format!("pty-body-{id}"))) .size_full() .p_2() .font_family(app.data.prefs.font_family.clone()) .overflow_hidden() - .on_key_down({ + .child(grid); + if interactive { + body.on_key_down({ let view = view.clone(); move |ev: &KeyDownEvent, window, cx| { if ev.keystroke.modifiers.control && ev.keystroke.key == "f" @@ -93,9 +105,12 @@ pub fn render(app: &mut AppView, index: usize, _window: &mut Window, cx: &mut Co } } }) - .child(grid), - ) - .when(search_open, |el| { + .into_any_element() + } else { + body.into_any_element() + } + }) + .when(search_open && interactive, |el| { el.child( h_flex() .id("term-search") @@ -178,10 +193,10 @@ pub fn render(app: &mut AppView, index: usize, _window: &mut Window, cx: &mut Co ), ) }) - .when(!clickables.is_empty(), |el| { + .when(interactive && !clickables.is_empty(), |el| { el.child( h_flex() - .id("pty-links") + .id(crate::ui::eid(format!("pty-links-{id}"))) .absolute() .bottom(px(8.)) .left(px(8.)) diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index deb7af3f..3b61a22f 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -423,10 +423,17 @@ fn visible(show: bool, child: impl IntoElement) -> impl IntoElement { fn main_column(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(ws) = app.data.current_ws() else { - return div().id("no-main").flex_1().into_any_element(); + return persistent_terminals(app, window, cx).into_any_element(); }; if !ws.has_tabs() { - return empty_cta(app, cx).into_any_element(); + return v_flex() + .id("workspace-empty") + .flex_1() + .min_h_0() + .relative() + .child(empty_cta(app, cx)) + .child(persistent_terminals(app, window, cx)) + .into_any_element(); } v_flex() .id("workspace-main") @@ -712,12 +719,10 @@ fn agent_kind_mark(kind: AgentKind) -> impl IntoElement { } fn tab_bodies(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let Some(ws) = app.data.current_ws() else { - return div().into_any_element(); - }; - let active = ws.active; - let term_count = ws.terminals.len(); - let file_active = matches!(active, Some(UnifiedTab::File { .. })); + let file_active = app + .data + .current_ws() + .is_some_and(|ws| matches!(ws.active, Some(UnifiedTab::File { .. }))); div() .id("tab-bodies") @@ -732,13 +737,34 @@ fn tab_bodies(app: &mut AppView, window: &mut Window, cx: &mut Context) .when(!file_active, |el| el.invisible().h(px(0.))) .child(file_viewer::render(app, window, cx)), ) - .children((0..term_count).map(|ix| { - let show = matches!(active, Some(UnifiedTab::Terminal { index }) if index == ix); + .child(persistent_terminals(app, window, cx)) + .into_any_element() +} + +fn persistent_terminals(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let current = app.data.current_profile.clone(); + let mut slots = app + .data + .workspaces + .iter() + .flat_map(|(profile_id, ws)| { + ws.terminals.iter().enumerate().map(|(index, term)| { + let visible = current.as_deref() == Some(profile_id.as_str()) + && matches!(ws.active, Some(UnifiedTab::Terminal { index: active }) if active == index); + (profile_id.clone(), index, term.id.clone(), visible) + }) + }) + .collect::>(); + slots.sort_by(|a, b| a.2.cmp(&b.2)); + + div() + .id("pty-layer") + .size_full() + .relative() + .children(slots.into_iter().map(|(profile_id, index, id, visible)| { div() - .id(crate::ui::eid(format!("term-slot-{ix}"))) + .id(crate::ui::eid(format!("term-slot-{id}"))) .size_full() - .when(!show, |el| el.invisible()) - .child(terminal::render(app, ix, window, cx)) + .child(terminal::render(app, &profile_id, index, visible, window, cx)) })) - .into_any_element() } From 5e712e5b935a2c06a0ec180428b7d027e6d96892 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 06:52:36 +0000 Subject: [PATCH 18/50] Match GPUI project groups and terminal settings to the inventory. Project context menus now list existing groups with a current-group check, remove, and inline create. Sort mode shows folder drop hints, and Settings uses Dark/Light theme labels plus preview, custom shell, and font empty states. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 66 ++++++++ src-gpui/src/state.rs | 81 ++++++++++ src-gpui/src/ui/dialogs.rs | 291 +++++++++++++++++++++++++++--------- src-gpui/src/ui/settings.rs | 183 ++++++++++++++++++----- src-gpui/src/ui/sidebar.rs | 37 +++-- 5 files changed, 543 insertions(+), 115 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index f680aa1e..c3313c23 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -1177,6 +1177,52 @@ impl AppView { self.reload_projects(); } + pub fn open_project_menu(&mut self, id: String, x: f32, y: f32, window: &mut Window, cx: &mut Context) { + self.inputs.group_name.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + self.data.overlay.group_menu_creating = self.data.groups.is_empty(); + self.data.overlay.context_menu = Some((ContextMenu::Project { id }, x, y)); + } + + pub fn assign_project_to_group(&mut self, project_id: &str, group_id: Option) { + if let Err(err) = self.backend.assign_to_group(project_id, group_id) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + return; + } + self.reload_projects(); + self.data.overlay.context_menu = None; + self.data.overlay.group_menu_creating = false; + } + + pub fn submit_create_group(&mut self, project_id: Option<&str>, cx: &mut Context) { + let name = self.inputs.group_name.read(cx).value().trim().to_string(); + if name.is_empty() { + return; + } + let pid = project_id + .map(|id| id.to_string()) + .or_else(|| self.data.overlay.dialog_project.clone()); + match self.backend.create_group(&name) { + Ok(group) => { + if let Some(pid) = pid { + if let Err(err) = self.backend.assign_to_group(&pid, Some(group.id)) { + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + } + } + self.reload_projects(); + self.data.overlay.dialog = None; + self.data.overlay.context_menu = None; + self.data.overlay.group_menu_creating = false; + } + Err(err) => self + .data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()), + } + } + pub fn apply_picked_folder(&mut self, folder: String, window: &mut Window, cx: &mut Context) { let empty = self.inputs.project_name.read(cx).value().trim().is_empty(); self.data.overlay.dialog_folder = Some(folder.clone()); @@ -1967,7 +2013,15 @@ impl AppView { /// Close the topmost overlay. Returns true when something was dismissed. pub fn dismiss_overlay(&mut self) -> bool { + if self.data.overlay.context_menu.is_some() + && self.data.overlay.group_menu_creating + && !self.data.groups.is_empty() + { + self.data.overlay.group_menu_creating = false; + return true; + } if self.data.overlay.context_menu.take().is_some() { + self.data.overlay.group_menu_creating = false; return true; } if self.data.overlay.renaming_path.take().is_some() { @@ -2095,6 +2149,18 @@ impl AppView { self.rename_dialog_project(cx); return true; } + if self.data.overlay.dialog == Some(DialogKind::CreateGroup) && key == "enter" { + self.submit_create_group(None, cx); + return true; + } + if key == "enter" { + if let Some((ContextMenu::Project { id }, _, _)) = self.data.overlay.context_menu.clone() { + if self.data.overlay.group_menu_creating || self.data.groups.is_empty() { + self.submit_create_group(Some(&id), cx); + return true; + } + } + } if self.data.overlay.palette_open { return match key { "up" => { diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index daa371ae..84865602 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -415,6 +415,44 @@ pub struct OverlayState { pub drag_file: Option, pub renaming_path: Option, pub update_checked: bool, + pub group_menu_creating: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GroupMenuRow { + Empty, + Group { id: String, name: String, current: bool }, + Remove, + Create, + CreateInput, +} + +pub fn project_group_menu_rows( + groups: &[(String, String)], + current_group: Option<&str>, + creating: bool, +) -> Vec { + let mut rows = Vec::new(); + if groups.is_empty() { + rows.push(GroupMenuRow::Empty); + } else { + for (id, name) in groups { + rows.push(GroupMenuRow::Group { + id: id.clone(), + name: name.clone(), + current: current_group == Some(id.as_str()), + }); + } + } + if current_group.is_some() { + rows.push(GroupMenuRow::Remove); + } + if creating || groups.is_empty() { + rows.push(GroupMenuRow::CreateInput); + } else { + rows.push(GroupMenuRow::Create); + } + rows } pub struct AppData { @@ -547,6 +585,49 @@ mod tests { ); } + #[test] + fn project_group_menu_rows_match_inventory() { + assert_eq!( + project_group_menu_rows(&[], None, false), + vec![GroupMenuRow::Empty, GroupMenuRow::CreateInput] + ); + assert_eq!( + project_group_menu_rows(&[("g1".into(), "Work".into())], None, false), + vec![ + GroupMenuRow::Group { + id: "g1".into(), + name: "Work".into(), + current: false, + }, + GroupMenuRow::Create, + ] + ); + assert_eq!( + project_group_menu_rows(&[("g1".into(), "Work".into())], Some("g1"), false), + vec![ + GroupMenuRow::Group { + id: "g1".into(), + name: "Work".into(), + current: true, + }, + GroupMenuRow::Remove, + GroupMenuRow::Create, + ] + ); + assert_eq!( + project_group_menu_rows(&[("g1".into(), "Work".into())], Some("g1"), true), + vec![ + GroupMenuRow::Group { + id: "g1".into(), + name: "Work".into(), + current: true, + }, + GroupMenuRow::Remove, + GroupMenuRow::CreateInput, + ] + ); + } + #[test] fn update_toasts_live_twelve_seconds() { let update = Toast { diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index 638cde9f..29ec6abf 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -7,7 +7,7 @@ use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::backend; -use crate::state::{ContextMenu, DialogKind}; +use crate::state::{project_group_menu_rows, ContextMenu, DialogKind, GroupMenuRow}; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() @@ -652,14 +652,7 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> app.open_url_with(""); } DialogKind::CreateGroup => { - let name = app.inputs.group_name.read(cx).value().to_string(); - if let Ok(group) = app.backend.create_group(&name) { - if let Some(pid) = app.data.overlay.dialog_project.clone() { - let _ = app.backend.assign_to_group(&pid, Some(group.id)); - } - app.reload_projects(); - } - app.data.overlay.dialog = None; + app.submit_create_group(None, cx); } DialogKind::ReviewQueue => { app.copy_review_comments(false, cx); @@ -691,6 +684,7 @@ fn context_menu(app: &AppView, cx: &mut Context) -> impl IntoElement { move |_, _, cx| { view.update(cx, |app, cx| { app.data.overlay.context_menu = None; + app.data.overlay.group_menu_creating = false; cx.notify(); }); } @@ -701,42 +695,73 @@ fn context_menu(app: &AppView, cx: &mut Context) -> impl IntoElement { .absolute() .left(px(x)) .top(px(y)) - .min_w(px(220.)) + .min_w(px(224.)) .py_1() .rounded_lg() .bg(theme.background) .border_1() .border_color(theme.border) .shadow_md() - .children(items.into_iter().map(|(label, danger, action)| { + .on_click(|_, _, _| {}) + .children(items.into_iter().map(|item| { + if item.input { + return div() + .id(crate::ui::eid(item.id.clone())) + .px_2() + .py_1() + .child(Input::new(&app.inputs.group_name)) + .into_any_element(); + } + let muted = item.muted || item.header; + let disabled = item.disabled; + let keep_open = item.keep_open || item.header || item.muted || item.disabled; div() - .id(crate::ui::eid(format!("ctx-{label}"))) + .id(crate::ui::eid(item.id.clone())) .px_3() .py_1() .text_sm() - .when(danger, |el| el.text_color(theme.danger)) - .hover(|el| el.bg(theme.muted)) + .when(item.danger, |el| el.text_color(theme.danger)) + .when(muted, |el| el.text_color(theme.muted_foreground)) + .when(item.disabled, |el| el.opacity(0.55)) + .when(!muted && !disabled, |el| el.hover(|el| el.bg(theme.muted))) .on_click({ let view = view.clone(); let menu = menu.clone(); + let action = item.action.clone(); move |_, window, cx| { + if disabled { + return; + } view.update(cx, |app, cx| { - run_menu(app, &menu, action, window, cx); - app.data.overlay.context_menu = None; + run_menu(app, &menu, action.clone(), window, cx); + if !keep_open { + app.data.overlay.context_menu = None; + app.data.overlay.group_menu_creating = false; + } cx.notify(); }); } }) - .child(label) + .child(if item.checked || matches!(item.action, MenuAction::AssignGroup(_)) { + h_flex() + .gap_2() + .child(div().w(px(12.)).child(if item.checked { "✓" } else { "" })) + .child(item.label) + .into_any_element() + } else { + div().child(item.label).into_any_element() + }) + .into_any_element() })), ) .into_any_element() } -#[derive(Clone, Copy)] +#[derive(Clone)] enum MenuAction { - AddToGroup, - CreateGroup, + AssignGroup(String), + StartCreateGroup, + SubmitCreateGroup, RemoveGroup, ProjectSettings, Rename, @@ -758,66 +783,202 @@ enum MenuAction { Header, } -fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec<(String, bool, MenuAction)> { +struct MenuItem { + id: String, + label: String, + danger: bool, + disabled: bool, + checked: bool, + muted: bool, + header: bool, + keep_open: bool, + input: bool, + action: MenuAction, +} + +fn item(id: impl Into, label: impl Into, action: MenuAction) -> MenuItem { + MenuItem { + id: id.into(), + label: label.into(), + danger: false, + disabled: false, + checked: false, + muted: false, + header: false, + keep_open: false, + input: false, + action, + } +} + +fn danger(mut item: MenuItem) -> MenuItem { + item.danger = true; + item +} + +fn header(id: impl Into, label: impl Into) -> MenuItem { + let mut item = item(id, label, MenuAction::Header); + item.header = true; + item.muted = true; + item +} + +fn project_menu_items(app: &AppView, project_id: &str) -> Vec { + let current = app + .data + .projects + .iter() + .find(|p| p.id == project_id) + .and_then(|p| p.group_id.clone()); + let groups: Vec<(String, String)> = app.data.groups.iter().map(|g| (g.id.clone(), g.name.clone())).collect(); + let rows = project_group_menu_rows(&groups, current.as_deref(), app.data.overlay.group_menu_creating); + let mut items = vec![header("add-group-h", app.t("addToProjectGroup"))]; + for row in rows { + match row { + GroupMenuRow::Empty => { + let mut empty = item("no-groups", app.t("noProjectGroups"), MenuAction::Header); + empty.muted = true; + items.push(empty); + } + GroupMenuRow::Group { id, name, current } => { + let mut row = item(format!("grp-{id}"), name, MenuAction::AssignGroup(id)); + row.checked = current; + row.disabled = current; + items.push(row); + } + GroupMenuRow::Remove => items.push(item( + "remove-group", + app.t("removeFromProjectGroup"), + MenuAction::RemoveGroup, + )), + GroupMenuRow::Create => { + let mut create = item( + "create-group", + app.t("createProjectGroup"), + MenuAction::StartCreateGroup, + ); + create.keep_open = true; + items.push(create); + } + GroupMenuRow::CreateInput => { + items.push(MenuItem { + id: "create-group-input".into(), + label: String::new(), + danger: false, + disabled: false, + checked: false, + muted: false, + header: false, + keep_open: true, + input: true, + action: MenuAction::SubmitCreateGroup, + }); + } + } + } + items.push(item( + "proj-settings", + app.t("projectSettings"), + MenuAction::ProjectSettings, + )); + items.push(item("rename-proj", app.t("renameProject"), MenuAction::Rename)); + items.push(danger(item( + "del-proj", + app.t("deleteProject"), + MenuAction::DeleteProject, + ))); + items +} + +fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec { match menu { - ContextMenu::Project { .. } => vec![ - (app.t("addToProjectGroup"), false, MenuAction::AddToGroup), - (app.t("createProjectGroup"), false, MenuAction::CreateGroup), - (app.t("removeFromProjectGroup"), false, MenuAction::RemoveGroup), - (app.t("projectSettings"), false, MenuAction::ProjectSettings), - (app.t("renameProject"), false, MenuAction::Rename), - (app.t("deleteProject"), true, MenuAction::DeleteProject), - ], - ContextMenu::Profile { .. } => vec![(app.t("deleteProfile"), true, MenuAction::DeleteProfile)], + ContextMenu::Project { id } => project_menu_items(app, id), + ContextMenu::Profile { .. } => vec![danger(item( + "del-prof", + app.t("deleteProfile"), + MenuAction::DeleteProfile, + ))], ContextMenu::File { .. } => vec![ - (app.t("fileTreeContextMenuOpen"), false, MenuAction::Open), - ( + item("open", app.t("fileTreeContextMenuOpen"), MenuAction::Open), + item( + "open-default", app.t("fileTreeContextMenuOpenInDefaultApp"), - false, MenuAction::OpenDefault, ), - ( + item( + "reveal", app.t("fileTreeContextMenuRevealInFileManager"), - false, MenuAction::Reveal, ), - (app.t("fileTreeContextMenuRefresh"), false, MenuAction::Refresh), - (app.t("fileTreeContextMenuNewFile"), false, MenuAction::NewFile), - (app.t("fileTreeContextMenuNewFolder"), false, MenuAction::NewFolder), - (app.t("rename"), false, MenuAction::RenamePath), - (app.t("fileTreeContextMenuCopyRelativePath"), false, MenuAction::CopyRel), - (app.t("fileTreeContextMenuCopyAbsolutePath"), false, MenuAction::CopyAbs), - (app.t("delete"), true, MenuAction::DeletePath), + item("refresh", app.t("fileTreeContextMenuRefresh"), MenuAction::Refresh), + item("new-file", app.t("fileTreeContextMenuNewFile"), MenuAction::NewFile), + item( + "new-folder", + app.t("fileTreeContextMenuNewFolder"), + MenuAction::NewFolder, + ), + item("rename-path", app.t("rename"), MenuAction::RenamePath), + item( + "copy-rel", + app.t("fileTreeContextMenuCopyRelativePath"), + MenuAction::CopyRel, + ), + item( + "copy-abs", + app.t("fileTreeContextMenuCopyAbsolutePath"), + MenuAction::CopyAbs, + ), + danger(item("del-path", app.t("delete"), MenuAction::DeletePath)), ], ContextMenu::TreeBlank => vec![ - (app.t("fileTreeContextMenuNewFile"), false, MenuAction::NewFile), - (app.t("fileTreeContextMenuNewFolder"), false, MenuAction::NewFolder), - (app.t("fileTreeContextMenuRefresh"), false, MenuAction::Refresh), - ( + item( + "blank-new-file", + app.t("fileTreeContextMenuNewFile"), + MenuAction::NewFile, + ), + item( + "blank-new-folder", + app.t("fileTreeContextMenuNewFolder"), + MenuAction::NewFolder, + ), + item( + "blank-refresh", + app.t("fileTreeContextMenuRefresh"), + MenuAction::Refresh, + ), + item( + "blank-reveal", app.t("fileTreeContextMenuRevealInFileManager"), - false, MenuAction::Reveal, ), - (app.t("fileTreeContextMenuCopyRelativePath"), false, MenuAction::CopyRel), - (app.t("fileTreeContextMenuCopyAbsolutePath"), false, MenuAction::CopyAbs), + item( + "blank-copy-rel", + app.t("fileTreeContextMenuCopyRelativePath"), + MenuAction::CopyRel, + ), + item( + "blank-copy-abs", + app.t("fileTreeContextMenuCopyAbsolutePath"), + MenuAction::CopyAbs, + ), ], ContextMenu::NewTerminal => { - let mut items = vec![(app.t("newTerminal"), false, MenuAction::NewTerm)]; + let mut items = vec![item("new-term", app.t("newTerminal"), MenuAction::NewTerm)]; let project = app .data .current_ws() .map(|w| w.config.terminal_templates.clone()) .unwrap_or_default(); if !project.is_empty() { - items.push((app.t("projectTerminalTemplates"), false, MenuAction::Header)); + items.push(header("proj-templates", app.t("projectTerminalTemplates"))); for (i, t) in project.iter().enumerate() { - items.push((t.name.clone(), false, MenuAction::ProjectTemplate(i))); + items.push(item(format!("pt-{i}"), t.name.clone(), MenuAction::ProjectTemplate(i))); } } if !app.data.prefs.templates.is_empty() { - items.push((app.t("globalTerminalTemplates"), false, MenuAction::Header)); + items.push(header("global-templates", app.t("globalTerminalTemplates"))); for (i, t) in app.data.prefs.templates.iter().enumerate() { - items.push((t.name.clone(), false, MenuAction::Template(i))); + items.push(item(format!("gt-{i}"), t.name.clone(), MenuAction::Template(i))); } } items @@ -844,22 +1005,18 @@ fn run_menu(app: &mut AppView, menu: &ContextMenu, action: MenuAction, window: & app.data.overlay.dialog = Some(DialogKind::ProjectSettings); app.data.overlay.dialog_project = Some(id.clone()); } - (ContextMenu::Project { id }, MenuAction::CreateGroup) => { - app.data.overlay.dialog = Some(DialogKind::CreateGroup); + (ContextMenu::Project { id }, MenuAction::StartCreateGroup) => { + app.data.overlay.group_menu_creating = true; app.data.overlay.dialog_project = Some(id.clone()); } - (ContextMenu::Project { id }, MenuAction::RemoveGroup) => { - let _ = app.backend.assign_to_group(id, None); - app.reload_projects(); + (ContextMenu::Project { id }, MenuAction::SubmitCreateGroup) => { + app.submit_create_group(Some(id), cx); } - (ContextMenu::Project { id }, MenuAction::AddToGroup) => { - if let Some(g) = app.data.groups.first() { - let _ = app.backend.assign_to_group(id, Some(g.id.clone())); - app.reload_projects(); - } else { - app.data.overlay.dialog = Some(DialogKind::CreateGroup); - app.data.overlay.dialog_project = Some(id.clone()); - } + (ContextMenu::Project { id }, MenuAction::AssignGroup(group_id)) => { + app.assign_project_to_group(id, Some(group_id)); + } + (ContextMenu::Project { id }, MenuAction::RemoveGroup) => { + app.assign_project_to_group(id, None); } (ContextMenu::Profile { id, .. }, MenuAction::DeleteProfile) => { app.prepare_delete_profile(id); diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 4e149a56..bedcff25 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -102,6 +102,7 @@ pub struct SettingsView { fonts: Vec, sounds: Vec, editing_template_id: Option, + preview_theme: Option, } impl SettingsView { @@ -130,9 +131,10 @@ impl SettingsView { s }) } - let custom_shell = inp(window, cx, "", &prefs.custom_shell, false); + let custom_shell = inp(window, cx, &crate::i18n::t(locale, "customShellPlaceholder"), &prefs.custom_shell, false); let worktree = inp(window, cx, "", &prefs.worktree_dir, false); custom_shell.update(cx, |s, cx| { + s.set_placeholder(crate::i18n::t(locale, "customShellPlaceholder"), window, cx); s.set_value(prefs.custom_shell.clone(), window, cx); }); worktree.update(cx, |s, cx| { @@ -156,6 +158,7 @@ impl SettingsView { fonts: crate::platform::list_mono_fonts(), sounds: crate::platform::list_system_sounds(), editing_template_id: None, + preview_theme: None, } } @@ -164,7 +167,11 @@ impl SettingsView { } fn persist(&mut self, cx: &mut Context) { - self.prefs.custom_shell = self.custom_shell.read(cx).value().to_string(); + let typed = self.custom_shell.read(cx).value().to_string(); + let listed = crate::platform::list_shells(); + if self.prefs.custom_shell.is_empty() || !listed.iter().any(|s| s == &self.prefs.custom_shell) { + self.prefs.custom_shell = typed; + } self.prefs.worktree_dir = self.worktree.read(cx).value().to_string(); self.prefs.language = self.locale; self.prefs.save(&self.backend.app_data_dir); @@ -364,9 +371,33 @@ impl SettingsView { )) } + fn preview_theme_name(&self, cx: &Context) -> String { + if let Some(name) = &self.preview_theme { + return name.clone(); + } + if !self.prefs.sync_terminal_theme && cx.theme().mode == ThemeMode::Light { + self.prefs.terminal_theme_light.clone() + } else { + self.prefs.terminal_theme_dark.clone() + } + } + fn terminal(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); - let theme = term_theme_by_name(&self.prefs.terminal_theme_dark); + let theme = term_theme_by_name(&self.preview_theme_name(cx)); + let shells = crate::platform::list_shells(); + let default_shell = crate::backend::default_shell(); + let custom_selected = self.prefs.custom_shell.is_empty() || !shells.iter().any(|s| s == &self.prefs.custom_shell); + let fonts = if self.prefs.show_all_fonts { + crate::platform::visible_font_families(true) + } else { + self.fonts.clone() + }; + let dark_label = if self.prefs.sync_terminal_theme { + self.t("terminalTheme") + } else { + self.t("terminalThemeDark") + }; h_flex() .id("settings-terminal") .size_full() @@ -375,7 +406,16 @@ impl SettingsView { v_flex() .max_w(px(448.)) .gap_4() - .child(field_label(&self.t("terminalTheme"))) + .child(theme_label_row(&dark_label, "preview-dark", self.t("preview"), { + let view = view.clone(); + let name = self.prefs.terminal_theme_dark.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.preview_theme = Some(name.clone()); + cx.notify(); + }); + } + })) .child( v_flex().gap_1().children(TERM_THEMES.iter().map(|t| { let selected = self.prefs.terminal_theme_dark == t.name; @@ -412,7 +452,16 @@ impl SettingsView { }, )) .when(!self.prefs.sync_terminal_theme, |el| { - el.child(field_label(&self.t("terminalTheme"))) + el.child(theme_label_row(&self.t("terminalThemeLight"), "preview-light", self.t("preview"), { + let view = view.clone(); + let name = self.prefs.terminal_theme_light.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.preview_theme = Some(name.clone()); + cx.notify(); + }); + } + })) .child( v_flex().gap_1().children(TERM_THEMES.iter().map(|t| { let selected = self.prefs.terminal_theme_light == t.name; @@ -432,22 +481,53 @@ impl SettingsView { }) .child(field_label(&self.t("defaultShell"))) .child( - h_flex().gap_1().flex_wrap().children(crate::platform::list_shells().into_iter().map(|shell| { - let selected = self.prefs.custom_shell == shell; - choice(format!("shell-{shell}"), &shell, selected, { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("defaultShellDescription")), + ) + .child({ + let mut chips: Vec = shells + .into_iter() + .map(|shell| { + let selected = self.prefs.custom_shell == shell; + let label = if shell == default_shell { + format!("{shell} (default)") + } else { + shell.clone() + }; + choice(format!("shell-{shell}"), &label, selected, { + let view = view.clone(); + let shell = shell.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.custom_shell = shell.clone(); + this.persist(cx); + cx.notify(); + }); + } + }) + .into_any_element() + }) + .collect(); + chips.push( + choice("shell-custom", &self.t("customShell"), custom_selected, { let view = view.clone(); - let shell = shell.clone(); move |cx| { view.update(cx, |this, cx| { - this.prefs.custom_shell = shell.clone(); + if crate::platform::list_shells().iter().any(|s| s == &this.prefs.custom_shell) { + this.prefs.custom_shell.clear(); + } this.persist(cx); cx.notify(); }); } }) - })), - ) - .child(Input::new(&self.custom_shell)) + .into_any_element(), + ); + h_flex().gap_1().flex_wrap().children(chips) + }) + .when(custom_selected, |el| el.child(Input::new(&self.custom_shell))) .child(field_label(&self.t("terminalFont"))) .child(div().text_sm().child(self.prefs.font_family.clone())) .child(switch_row( @@ -466,32 +546,37 @@ impl SettingsView { } }, )) - .child( - div() - .max_h(px(180.)) - .overflow_y_hidden() - .child({ - let fonts = if self.prefs.show_all_fonts { - crate::platform::visible_font_families(true) - } else { - self.fonts.clone() - }; - h_flex().gap_1().flex_wrap().children(fonts.into_iter().map(|family| { - let selected = self.prefs.font_family == family; - choice(format!("font-{family}"), &family, selected, { - let view = view.clone(); - let family = family.clone(); - move |cx| { - view.update(cx, |this, cx| { - this.prefs.font_family = family.clone(); - this.persist(cx); - cx.notify(); - }); - } - }) - })) - }), - ) + .when(fonts.is_empty(), |el| { + el.child(div().text_sm().child(self.t("fontPickerUnavailable"))).child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("fontPickerUnavailableDescription")), + ) + }) + .when(!fonts.is_empty(), |el| { + el.child( + div() + .max_h(px(180.)) + .overflow_y_hidden() + .child( + h_flex().gap_1().flex_wrap().children(fonts.into_iter().map(|family| { + let selected = self.prefs.font_family == family; + choice(format!("font-{family}"), &family, selected, { + let view = view.clone(); + let family = family.clone(); + move |cx| { + view.update(cx, |this, cx| { + this.prefs.font_family = family.clone(); + this.persist(cx); + cx.notify(); + }); + } + }) + })), + ), + ) + }) .child(field_label(&self.t("fontSize"))) .child( h_flex() @@ -1108,6 +1193,26 @@ fn field_label(text: &str) -> impl IntoElement { div().font_medium().text_sm().child(text.to_string()) } +fn theme_label_row( + label: &str, + preview_id: &'static str, + preview_label: String, + on_preview: impl Fn(&mut App) + 'static, +) -> impl IntoElement { + h_flex() + .w_full() + .justify_between() + .child(field_label(label)) + .child( + Button::new(preview_id) + .ghost() + .xsmall() + .icon(IconName::Eye) + .tooltip(preview_label) + .on_click(move |_, _, cx| on_preview(cx)), + ) +} + fn choice( id: impl Into, label: &str, diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index 4114a95a..94ce9a23 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -168,14 +168,14 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) } fn drop_zone( - id: &'static str, + id: impl Into, label: String, border: gpui::Hsla, muted: gpui::Hsla, on_drop: impl Fn(&SidebarDrag, &mut gpui::App) + 'static, ) -> impl IntoElement { div() - .id(id) + .id(id.into()) .mt_2() .px_2() .py_3() @@ -296,7 +296,7 @@ fn project_sections(app: &mut AppView, _window: &mut Window, cx: &mut Context Date: Thu, 3 Sep 2026 06:54:40 +0000 Subject: [PATCH 19/50] Show GPUI file find on demand and surface sidebar layout errors. Cmd+F opens an i18n find bar on the file viewer instead of keeping it always visible. Sidebar pin, drop, and reorder failures now use the inventory error copy, and the default shell chip uses the defaultOption label. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 23 +++++++++++++++++--- src-gpui/src/state.rs | 1 + src-gpui/src/ui/file_viewer.rs | 38 +++++++++++++++++++++++++++------- src-gpui/src/ui/settings.rs | 2 +- src-gpui/src/ui/sidebar.rs | 12 ++++------- 5 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index c3313c23..be363228 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -140,7 +140,7 @@ impl AppView { .line_number(true) .soft_wrap(false) }), - file_search: input(window, cx, "Find", false), + file_search: input(window, cx, &i18n::t(locale, "fileViewerFindInFile"), false), debug_search: input(window, cx, "", false), branch_search: input(window, cx, "", false), new_path: input(window, cx, "", false), @@ -188,6 +188,9 @@ impl AppView { inputs.term_search.update(cx, |s, cx| { s.set_placeholder(i18n::t(locale, "terminalSearchPlaceholder"), window, cx); }); + inputs.file_search.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "fileViewerFindInFile"), window, cx); + }); inputs.review_comment.update(cx, |s, cx| { s.set_placeholder(i18n::t(locale, "gitReviewCommentPlaceholder"), window, cx); }); @@ -1034,6 +1037,7 @@ impl AppView { .and_then(|w| w.active.as_ref()) .is_some_and(|tab| matches!(tab, UnifiedTab::File { .. })); if file_tab { + self.data.overlay.file_search_open = true; self.inputs.file_search.update(cx, |input, cx| { input.focus(window, cx); }); @@ -1147,7 +1151,16 @@ impl AppView { pub fn move_sidebar_project(&mut self, id: &str, delta: i32) { if let Err(err) = self.backend.move_project(id, delta) { self.data - .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + .push_toast(ToastKind::Error, self.t("sidebarOrderUpdateFailed"), err.to_string()); + return; + } + self.reload_projects(); + } + + pub fn set_project_pinned(&mut self, id: &str, pinned: bool) { + if let Err(err) = self.backend.set_pinned(id, pinned) { + self.data + .push_toast(ToastKind::Error, self.t("sidebarOrderUpdateFailed"), err.to_string()); return; } self.reload_projects(); @@ -1171,7 +1184,7 @@ impl AppView { pub fn drop_sidebar_project(&mut self, dragged: &str, target: Option<&str>, unpin: bool) { if let Err(err) = self.backend.drop_project(dragged, target, unpin) { self.data - .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()); + .push_toast(ToastKind::Error, self.t("sidebarOrderUpdateFailed"), err.to_string()); return; } self.reload_projects(); @@ -2027,6 +2040,10 @@ impl AppView { if self.data.overlay.renaming_path.take().is_some() { return true; } + if self.data.overlay.file_search_open { + self.data.overlay.file_search_open = false; + return true; + } if self.data.overlay.palette_open { self.data.overlay.palette_open = false; self.data.overlay.palette_results.clear(); diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 84865602..72620a25 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -416,6 +416,7 @@ pub struct OverlayState { pub renaming_path: Option, pub update_checked: bool, pub group_menu_creating: bool, + pub file_search_open: bool, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 51ac7a66..ea18794a 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -4,7 +4,7 @@ use gpui::{div, img, prelude::*, px, rgb, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; use gpui_component::text::TextView; -use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, Sizable, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; use crate::app::AppView; use crate::backend; @@ -148,13 +148,35 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .gap_2() .border_b_1() .border_color(theme.border) - .child(div().flex_1().child(Input::new(&app.inputs.file_search))) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(if q.is_empty() { String::new() } else { format!("{hits}") }), - ) + .when(app.data.overlay.file_search_open, |el| { + el.child(div().flex_1().child(Input::new(&app.inputs.file_search))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(if q.is_empty() { + app.t("fileViewerFindInFile") + } else { + format!("{hits}") + }), + ) + .child( + Button::new("file-find-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(app.t("fileViewerCloseFileSearch")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.file_search_open = false; + cx.notify(); + }); + } + }), + ) + }) .child( Button::new("file-save") .xsmall() diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index bedcff25..87967350 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -492,7 +492,7 @@ impl SettingsView { .map(|shell| { let selected = self.prefs.custom_shell == shell; let label = if shell == default_shell { - format!("{shell} (default)") + format!("{shell} ({})", self.t("defaultOption")) } else { shell.clone() }; diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index 94ce9a23..3062adf5 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -271,8 +271,7 @@ fn project_sections(app: &mut AppView, _window: &mut Window, cx: &mut Context Date: Thu, 3 Sep 2026 06:58:08 +0000 Subject: [PATCH 20/50] Add GPUI file-find cycling, GitHub avatars, and top-bar labels. Cmd+F find now steps through matches with prev/next and Enter/Shift+Enter. Sidebar project rows load GitHub avatar URLs when present, Settings Top Bar uses inventory labels and empty states, and project templates show their relative cwd. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 53 +++++++++++++++++++++++++++++++++- src-gpui/src/state.rs | 2 ++ src-gpui/src/ui/dialogs.rs | 7 ++++- src-gpui/src/ui/file_viewer.rs | 42 +++++++++++++++++++++++++-- src-gpui/src/ui/settings.rs | 24 ++++++++++++++- src-gpui/src/ui/sidebar.rs | 9 ++++-- 6 files changed, 129 insertions(+), 8 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index be363228..b711063f 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -122,6 +122,7 @@ impl AppView { notes_dirty_since: None, notes_bound_profile: None, file_dirty_since: None, + avatars: HashMap::new(), }; let inputs = Inputs { @@ -302,6 +303,12 @@ impl AppView { Err(err) => self.data.sidebar_error = Some(err.to_string()), } self.data.groups = self.backend.list_groups().unwrap_or_default(); + self.data.avatars = self + .data + .projects + .iter() + .filter_map(|p| self.backend.github_avatar(&p.id).map(|url| (p.id.clone(), url))) + .collect(); } pub fn open_profile(&mut self, project_id: &str, profile_id: &str) { @@ -1038,6 +1045,7 @@ impl AppView { .is_some_and(|tab| matches!(tab, UnifiedTab::File { .. })); if file_tab { self.data.overlay.file_search_open = true; + self.data.overlay.file_search_ix = 0; self.inputs.file_search.update(cx, |input, cx| { input.focus(window, cx); }); @@ -1064,6 +1072,30 @@ impl AppView { } } + pub fn cycle_file_search(&mut self, window: &mut Window, cx: &mut Context, next: bool) { + let query = self.inputs.file_search.read(cx).value().to_string(); + let draft = self.inputs.file_editor.read(cx).value().to_string(); + let hits = search_match_offsets(&draft, &query); + if hits.is_empty() { + self.data.overlay.file_search_ix = 0; + return; + } + if next { + self.data.overlay.file_search_ix = (self.data.overlay.file_search_ix + 1) % hits.len(); + } else { + self.data.overlay.file_search_ix = if self.data.overlay.file_search_ix == 0 { + hits.len() - 1 + } else { + self.data.overlay.file_search_ix - 1 + }; + } + let offset = hits[self.data.overlay.file_search_ix]; + self.inputs.file_editor.update(cx, |state, cx| { + let (line, character) = offset_line_col(&draft, offset); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + } + pub fn cycle_term_search(&mut self, cx: &mut Context, next: bool) { let query = self.inputs.term_search.read(cx).value().to_string(); if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { @@ -2178,6 +2210,10 @@ impl AppView { } } } + if self.data.overlay.file_search_open && key == "enter" { + self.cycle_file_search(window, cx, !shift); + return true; + } if self.data.overlay.palette_open { return match key { "up" => { @@ -2470,6 +2506,13 @@ pub fn suggested_project_name(folder: &str, current_name: &str) -> String { } } +pub fn search_match_offsets(text: &str, query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } + text.match_indices(query).map(|(ix, _)| ix).collect() +} + pub fn offset_line_col(text: &str, offset: usize) -> (u32, u32) { let mut cursor = offset.min(text.len()); while cursor > 0 && !text.is_char_boundary(cursor) { @@ -2505,7 +2548,8 @@ pub fn wrap_markup( #[cfg(test)] mod tests { use super::{ - file_status_badge, git_status_kind, offset_line_col, suggested_project_name, unique_tree_name, GitStatusKind, + file_status_badge, git_status_kind, offset_line_col, search_match_offsets, suggested_project_name, + unique_tree_name, GitStatusKind, }; #[test] @@ -2543,4 +2587,11 @@ mod tests { assert_eq!(offset_line_col("ab\ncd", 4), (1, 1)); assert_eq!(offset_line_col("", 0), (0, 0)); } + + #[test] + fn search_match_offsets_finds_each_hit() { + assert_eq!(search_match_offsets("abcabc", "bc"), vec![1, 4]); + assert!(search_match_offsets("abc", "").is_empty()); + assert!(search_match_offsets("abc", "z").is_empty()); + } } diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 72620a25..997ad90d 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -417,6 +417,7 @@ pub struct OverlayState { pub update_checked: bool, pub group_menu_creating: bool, pub file_search_open: bool, + pub file_search_ix: usize, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -472,6 +473,7 @@ pub struct AppData { pub notes_dirty_since: Option, pub notes_bound_profile: Option, pub file_dirty_since: Option, + pub avatars: HashMap, } impl AppData { diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index 29ec6abf..0e6161b1 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -972,7 +972,12 @@ fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec { if !project.is_empty() { items.push(header("proj-templates", app.t("projectTerminalTemplates"))); for (i, t) in project.iter().enumerate() { - items.push(item(format!("pt-{i}"), t.name.clone(), MenuAction::ProjectTemplate(i))); + let label = if t.cwd.is_empty() { + t.name.clone() + } else { + format!("{name} — {cwd}", name = t.name, cwd = t.cwd) + }; + items.push(item(format!("pt-{i}"), label, MenuAction::ProjectTemplate(i))); } } if !app.data.prefs.templates.is_empty() { diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index ea18794a..44d83633 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -130,10 +130,12 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) } let q = app.inputs.file_search.read(cx).value().to_string(); - let hits = if q.is_empty() { + let hits = crate::app::search_match_offsets(&file.draft, &q); + let hit_count = hits.len(); + let hit_ix = if hit_count == 0 { 0 } else { - file.draft.matches(&q).count() + app.data.overlay.file_search_ix % hit_count + 1 }; v_flex() .id("text-viewer") @@ -156,8 +158,42 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .text_color(theme.muted_foreground) .child(if q.is_empty() { app.t("fileViewerFindInFile") + } else if hit_count == 0 { + app.t("terminalSearchNoResults") } else { - format!("{hits}") + format!("{hit_ix}/{hit_count}") + }), + ) + .child( + Button::new("file-find-prev") + .ghost() + .xsmall() + .icon(IconName::ChevronUp) + .tooltip(app.t("fileViewerPreviousMatch")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.cycle_file_search(window, cx, false); + cx.notify(); + }); + } + }), + ) + .child( + Button::new("file-find-next") + .ghost() + .xsmall() + .icon(IconName::ChevronDown) + .tooltip(app.t("fileViewerNextMatch")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.cycle_file_search(window, cx, true); + cx.notify(); + }); + } }), ) .child( diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 87967350..f264081a 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -867,7 +867,13 @@ impl SettingsView { .rounded_md() .border_1() .border_color(cx.theme().border) - .child(div().flex_1().child(id.clone())) + .child(div().flex_1().child(match id.as_str() { + "github-desktop" => self.t("topbarGithubDesktop"), + "editor" => self.t("topbarEditor"), + "terminal" => self.t("topbarTerminal"), + "pr-status" => self.t("topbarPrStatus"), + _ => id.clone(), + })) .child( Button::new(crate::ui::eid(format!("tb-up-{id}"))) .ghost() @@ -906,8 +912,24 @@ impl SettingsView { ) })), ) + .when(self.prefs.topbar_controls.is_empty(), |el| { + el.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("topbarNoControls")), + ) + }) .child(div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDragHint"))) .child(div().font_semibold().child(self.t("topbarAvailable"))) + .when(all.iter().all(|id| self.prefs.topbar_controls.iter().any(|x| x == *id)), |el| { + el.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("topbarAllControlsActive")), + ) + }) .children(all.into_iter().map(|id| { let on = self.prefs.topbar_controls.iter().any(|x| x == id); switch_row( diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index 3062adf5..de7d1ab8 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -1,4 +1,4 @@ -use gpui::{div, prelude::*, px, Context, KeyDownEvent, MouseButton, Window}; +use gpui::{div, img, prelude::*, px, Context, KeyDownEvent, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; use gpui_component::{Disableable, Selectable}; @@ -634,8 +634,13 @@ fn project_row( .flex() .items_center() .justify_center() + .overflow_hidden() .text_xs() - .child(letter.to_string()), + .child(if let Some(url) = app.data.avatars.get(&project.id).cloned() { + img(url).size(px(16.)).into_any_element() + } else { + div().child(letter.to_string()).into_any_element() + }), ) }) .child( From 209b1560de38ad2b13a4a3e36fffd64387329164 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 07:05:26 +0000 Subject: [PATCH 21/50] Match GPUI git previews, palette copy, and settings labels. Show rename paths and HEAD/worktree image sides in the git viewer, add command-palette parent paths and inventory strings, and surface template/worktree/updater copy that the Tauri UI already had. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 50 +++++++++++++++++++++--- src-gpui/src/backend.rs | 21 ++++++++++ src-gpui/src/diff.rs | 23 +++++++++++ src-gpui/src/ui/dialogs.rs | 43 ++++++++++++++++++++ src-gpui/src/ui/git.rs | 66 ++++++++++++++++++++++++++++--- src-gpui/src/ui/palette.rs | 53 +++++++++++++++++++++---- src-gpui/src/ui/settings.rs | 76 ++++++++++++++++++++++++++++++++---- src-gpui/src/ui/sidebar.rs | 32 ++++++++++++++- src-gpui/src/ui/workspace.rs | 8 ++-- src-gpui/src/updater.rs | 24 ++++++++++++ 10 files changed, 366 insertions(+), 30 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index b711063f..1fb4eac3 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -199,6 +199,24 @@ impl AppView { s.set_placeholder(i18n::t(locale, "defaultWorktreeDirPlaceholder"), window, cx); s.set_value(prefs.worktree_dir.clone(), window, cx); }); + inputs.init_script.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "scriptPlaceholder"), window, cx); + }); + inputs.setup_script.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "scriptPlaceholder"), window, cx); + }); + inputs.teardown_script.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "scriptPlaceholder"), window, cx); + }); + inputs.template_name.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "terminalTemplateNamePlaceholder"), window, cx); + }); + inputs.template_shell.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "terminalTemplateShellPlaceholder"), window, cx); + }); + inputs.template_cwd.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "terminalTemplateCwdPlaceholder"), window, cx); + }); inputs.custom_shell.update(cx, |s, cx| { s.set_placeholder(i18n::t(locale, "customShellPlaceholder"), window, cx); s.set_value(prefs.custom_shell.clone(), window, cx); @@ -1418,14 +1436,29 @@ impl AppView { ], )); } + if check.total_diff.files_changed > 0 { + parts.push(i18n::tf( + self.data.locale, + "deleteProfileTotalDiffWarning", + &[ + ("files", &check.total_diff.files_changed.to_string()), + ("insertions", &check.total_diff.insertions.to_string()), + ("deletions", &check.total_diff.deletions.to_string()), + ], + )); + } if !parts.is_empty() { self.data.overlay.delete_warning = Some(parts.join("\n")); } } Err(err) => { self.data.overlay.dialog_busy = false; - self.data.overlay.delete_warning = - Some(format!("{}\n{}", self.t("deleteProfileGitCheckFailedTitle"), err)); + self.data.overlay.delete_warning = Some(format!( + "{}\n{}\n{}", + self.t("deleteProfileGitCheckFailedTitle"), + self.t("deleteProfileGitCheckFailedDescription"), + err + )); } } } @@ -1877,9 +1910,16 @@ impl AppView { self.data.overlay.dialog = Some(DialogKind::ChooseFile); self.data.overlay.fuzzy_files = candidates; } - Err(err) => self - .data - .push_toast(ToastKind::Error, self.t("somethingWentWrong"), err.to_string()), + Err(err) => { + let raw = err.to_string(); + let body = if raw.to_ascii_lowercase().contains("outside the workspace") { + self.t("terminalFilePathOutsideWorkspace") + } else { + raw + }; + self.data + .push_toast(ToastKind::Error, self.t("somethingWentWrong"), body); + } } } } diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index b1efd6e4..432500d2 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -555,6 +555,27 @@ pub fn is_previewable(path: &str) -> bool { .any(|ext| lower.ends_with(ext)) } +impl Backend { + pub fn cached_head_blob(&self, folder: &str, path: &str) -> Option { + let slug = path.replace(['/', '\\'], "_"); + let dest = self.app_data_dir.join("git-preview").join(slug); + if dest.exists() { + return Some(dest); + } + std::fs::create_dir_all(dest.parent()?).ok()?; + let spec = format!("HEAD:{path}"); + let out = std::process::Command::new("git") + .args(["-C", folder, "show", &spec]) + .output() + .ok()?; + if !out.status.success() || out.stdout.is_empty() { + return None; + } + std::fs::write(&dest, &out.stdout).ok()?; + Some(dest) + } +} + pub fn is_image(path: &str) -> bool { let lower = path.to_ascii_lowercase(); [ diff --git a/src-gpui/src/diff.rs b/src-gpui/src/diff.rs index a876ff52..965f880e 100644 --- a/src-gpui/src/diff.rs +++ b/src-gpui/src/diff.rs @@ -57,6 +57,22 @@ pub fn split_rows(diff: &str) -> Vec { rows } +pub fn rename_paths(diff: &str) -> Option<(String, String)> { + let mut from = None; + let mut to = None; + for line in diff.lines() { + if let Some(rest) = line.strip_prefix("rename from ") { + from = Some(rest.to_string()); + } else if let Some(rest) = line.strip_prefix("rename to ") { + to = Some(rest.to_string()); + } + } + match (from, to) { + (Some(old), Some(new)) if old != new => Some((old, new)), + _ => None, + } +} + fn is_header(line: &str) -> bool { line.starts_with("diff ") || line.starts_with("index ") @@ -123,4 +139,11 @@ mod tests { assert_eq!(rows[1].left.as_ref().unwrap().0, DiffLineKind::Del); assert_eq!(rows[1].right.as_ref().unwrap().0, DiffLineKind::Add); } + + #[test] + fn rename_paths_reads_git_headers() { + let diff = "diff --git a/old.rs b/new.rs\nrename from old.rs\nrename to new.rs\n"; + assert_eq!(rename_paths(diff), Some(("old.rs".into(), "new.rs".into()))); + assert_eq!(rename_paths("diff --git a/a.rs b/a.rs\n"), None); + } } diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index 0e6161b1..f2da6913 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -255,10 +255,28 @@ fn dialog_body( ) .child(Input::new(&app.inputs.worktree)) .child(div().text_sm().child(app.t("initScript"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("initScriptDesc")), + ) .child(Input::new(&app.inputs.init_script)) .child(div().text_sm().child(app.t("setupScript"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("setupScriptDesc")), + ) .child(Input::new(&app.inputs.setup_script)) .child(div().text_sm().child(app.t("teardownScript"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("teardownScriptDesc")), + ) .child(Input::new(&app.inputs.teardown_script)) .into_any_element() } else { @@ -335,8 +353,20 @@ fn dialog_body( .child(div().text_xs().child(app.t("terminalTemplateName"))) .child(Input::new(&app.inputs.template_name)) .child(div().text_xs().child(app.t("terminalTemplateCwd"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("terminalTemplateCwdDescription")), + ) .child(Input::new(&app.inputs.template_cwd)) .child(div().text_xs().child(app.t("terminalTemplateCommands"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("terminalTemplateCommandsDescription")), + ) .child(Input::new(&app.inputs.template_commands)) .child( Button::new("add-project-tpl") @@ -462,6 +492,13 @@ fn dialog_body( DialogKind::OpenLink => v_flex() .gap_2() .child(div().text_sm().child(app.t("terminalOpenLinkConfirmDescription"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .font_family("monospace") + .child(app.t("terminalOpenLinkUrlLabel")), + ) .child( div() .font_family("monospace") @@ -986,6 +1023,12 @@ fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec { items.push(item(format!("gt-{i}"), t.name.clone(), MenuAction::Template(i))); } } + if project.is_empty() && app.data.prefs.templates.is_empty() { + items.push(header("no-tpl", app.t("noTerminalTemplates"))); + let mut hint = item("no-tpl-hint", app.t("noTemplatesDropdownHint"), MenuAction::Header); + hint.muted = true; + items.push(hint); + } items } } diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index d1889796..ca0e50cd 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -1,4 +1,4 @@ -use gpui::{div, prelude::*, px, rgb, Context, Window}; +use gpui::{div, img, prelude::*, px, rgb, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::checkbox::Checkbox; use gpui_component::input::Input; @@ -531,13 +531,22 @@ pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Cont ) .into_any_element() } else if selected.as_ref().is_some_and(|p| backend::is_image(p)) { - let worktree = app.data.current_ws().map(|w| w.worktree.clone()).unwrap_or_default(); - let path = - std::path::PathBuf::from(worktree).join(selected.as_deref().unwrap_or_default()); - gpui::img(path).w_full().max_h(px(480.)).into_any_element() + image_preview(app, selected.as_deref().unwrap_or_default(), &diff).into_any_element() } else { v_flex() .gap_2() + .children(diff::rename_paths(&diff).map(|(old, new)| { + v_flex() + .gap_1() + .child(div().text_xs().text_color(theme.muted_foreground).child(format!( + "{label}: {old}", + label = app.t("gitDiffRenamePreviousPath") + ))) + .child(div().text_xs().text_color(theme.muted_foreground).child(format!( + "{label}: {new}", + label = app.t("gitDiffRenameCurrentPath") + ))) + })) .child(diff_stats(&diff)) .child(diff_view( &diff, @@ -857,3 +866,50 @@ fn split_cell( }) .child(shown) } + +fn image_preview(app: &AppView, path: &str, diff: &str) -> impl IntoElement { + let worktree = app.data.current_ws().map(|w| w.worktree.clone()).unwrap_or_default(); + let after = std::path::PathBuf::from(&worktree).join(path); + let before_rel = diff::rename_paths(diff) + .map(|(old, _)| old) + .unwrap_or_else(|| path.to_string()); + let before = app.backend.cached_head_blob(&worktree, &before_rel); + h_flex() + .id("git-image-preview") + .w_full() + .gap_4() + .child( + v_flex() + .flex_1() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .child(app.t("gitDiffImagePreviewBefore")), + ) + .child(if let Some(before) = before { + img(before).w_full().max_h(px(480.)).into_any_element() + } else { + div() + .text_sm() + .text_color(gpui::hsla(0., 0., 0.5, 1.)) + .child(app.t("gitDiffImagePreviewUnavailable")) + .into_any_element() + }), + ) + .child( + v_flex() + .flex_1() + .gap_1() + .child(div().text_xs().font_semibold().child(app.t("gitDiffImagePreviewAfter"))) + .child(if after.exists() { + img(after).w_full().max_h(px(480.)).into_any_element() + } else { + div() + .text_sm() + .child(app.t("gitDiffImagePreviewUnavailable")) + .into_any_element() + }), + ) +} diff --git a/src-gpui/src/ui/palette.rs b/src-gpui/src/ui/palette.rs index 4d73353a..6f57948a 100644 --- a/src-gpui/src/ui/palette.rs +++ b/src-gpui/src/ui/palette.rs @@ -1,10 +1,17 @@ use gpui::{div, prelude::*, px, Context, KeyDownEvent, Window}; use gpui_component::input::Input; -use gpui_component::{h_flex, v_flex, ActiveTheme, Icon}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, StyledExt}; use crate::app::AppView; -pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { +pub fn parent_label(relative_path: &str, root_label: &str) -> String { + match relative_path.rfind('/') { + Some(ix) if ix > 0 => relative_path[..ix].to_string(), + _ => root_label.to_string(), + } +} + +pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if !app.data.overlay.palette_open { return div().id("palette-closed").into_any_element(); } @@ -60,19 +67,37 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) } }) .child( - div() + v_flex() .px_4() .py_3() + .gap_1() .border_b_1() .border_color(theme.border) + .child(div().text_xs().font_semibold().child(app.t("commandPaletteTitle"))) .child(Input::new(&app.inputs.palette)), ) .child(if q.is_empty() { - div() + v_flex() .p_4() - .text_sm() - .text_color(theme.muted_foreground) - .child(app.t("commandPaletteEmpty")) + .gap_1() + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("commandPaletteEmpty")), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("commandPaletteHint")), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("commandPaletteOpenHint")), + ) .into_any_element() } else if results.is_empty() { v_flex() @@ -116,7 +141,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) div() .text_xs() .text_color(theme.muted_foreground) - .child(r.relative_path.clone()), + .child(parent_label(&r.relative_path, &app.t("commandPaletteRoot"))), ) })) .into_any_element() @@ -148,3 +173,15 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) ) .into_any_element() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parent_label_uses_directory_or_root() { + assert_eq!(parent_label("src/app.rs", "Project root"), "src"); + assert_eq!(parent_label("README.md", "Project root"), "Project root"); + assert_eq!(parent_label("src/ui/palette.rs", "根目录"), "src/ui"); + } +} diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index f264081a..020cd045 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -98,6 +98,7 @@ pub struct SettingsView { template_cmds: gpui::Entity, update_status: String, latest_version: Option, + released_at: Option, latest_url: String, fonts: Vec, sounds: Vec, @@ -132,7 +133,7 @@ impl SettingsView { }) } let custom_shell = inp(window, cx, &crate::i18n::t(locale, "customShellPlaceholder"), &prefs.custom_shell, false); - let worktree = inp(window, cx, "", &prefs.worktree_dir, false); + let worktree = inp(window, cx, &crate::i18n::t(locale, "defaultWorktreeDirPlaceholder"), &prefs.worktree_dir, false); custom_shell.update(cx, |s, cx| { s.set_placeholder(crate::i18n::t(locale, "customShellPlaceholder"), window, cx); s.set_value(prefs.custom_shell.clone(), window, cx); @@ -148,12 +149,13 @@ impl SettingsView { main, custom_shell, worktree, - template_name: inp(window, cx, "", "", false), - template_shell: inp(window, cx, "", "", false), - template_cwd: inp(window, cx, "", "", false), - template_cmds: inp(window, cx, "", "", true), + template_name: inp(window, cx, &crate::i18n::t(locale, "terminalTemplateNamePlaceholder"), "", false), + template_shell: inp(window, cx, &crate::i18n::t(locale, "terminalTemplateShellPlaceholder"), "", false), + template_cwd: inp(window, cx, &crate::i18n::t(locale, "terminalTemplateCwdPlaceholder"), "", false), + template_cmds: inp(window, cx, &crate::i18n::t(locale, "scriptPlaceholder"), "", true), update_status: crate::i18n::t(locale, "updateIdleDescription"), latest_version: None, + released_at: None, latest_url: crate::updater::releases_page().to_string(), fonts: crate::platform::list_mono_fonts(), sounds: crate::platform::list_system_sounds(), @@ -320,7 +322,51 @@ impl SettingsView { .text_color(cx.theme().muted_foreground) .child(self.t("defaultWorktreeDirDesc")), ) - .child(Input::new(&self.worktree)) + .child( + h_flex() + .gap_2() + .child(div().flex_1().min_w_0().child(Input::new(&self.worktree))) + .child( + Button::new("pick-worktree") + .small() + .icon(IconName::Folder) + .label(self.t("chooseFolder")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + if let Some(folder) = crate::backend::pick_folder() { + view.update(cx, |this, cx| { + this.worktree.update(cx, |s, cx| { + s.set_value(folder, window, cx); + }); + this.persist(cx); + cx.notify(); + }); + } + } + }), + ) + .child( + Button::new("clear-worktree") + .ghost() + .small() + .icon(IconName::Close) + .tooltip(self.t("clearDefaultWorktreeDir")) + .disabled(self.worktree.read(cx).value().is_empty()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| { + this.worktree.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + this.persist(cx); + cx.notify(); + }); + } + }), + ), + ) .child(switch_row( "debug-mode", self.t("debugMode"), @@ -1087,6 +1133,17 @@ impl SettingsView { .child(link_btn("web", self.t("website"), "https://github.com/AkaraChen/2code")), ) .child(div().text_sm().child(self.update_status.clone())) + .child( + if let Some(date) = &self.released_at { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(crate::i18n::tf(self.locale, "updateReleasedAt", &[("date", date)])) + .into_any_element() + } else { + div().into_any_element() + }, + ) .child( h_flex() .gap_2() @@ -1103,6 +1160,7 @@ impl SettingsView { match &result { Ok(info) if info.available => { this.latest_version = Some(info.latest_version.clone()); + this.released_at = info.released_at.clone(); this.latest_url = info.html_url.clone(); this.update_status = crate::i18n::tf( this.locale, @@ -1112,6 +1170,7 @@ impl SettingsView { } Ok(_) => { this.latest_version = None; + this.released_at = None; this.update_status = this.t("updateNotAvailableDescription"); } @@ -1158,7 +1217,10 @@ impl SettingsView { cx.quit(); } Err(err) => { - this.update_status = err; + this.update_status = format!( + "{}: {err}", + this.t("updateInstallFailedTitle") + ); let _ = open::that(&url); } } diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index de7d1ab8..97ac5c9b 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -400,7 +400,37 @@ fn project_sections(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { .text_color(theme.muted_foreground) .child(app.t("noTerminalsOpenDescription")), ) - .child(new_terminal_control(app, view)) + .child(new_terminal_control(app, view, true)) } fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { @@ -618,18 +618,18 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { } }) })) - .child(new_terminal_control(app, view)) + .child(new_terminal_control(app, view, false)) .into_any_element() } -fn new_terminal_control(app: &AppView, view: gpui::Entity) -> impl IntoElement { +fn new_terminal_control(app: &AppView, view: gpui::Entity, force_split: bool) -> impl IntoElement { let has_templates = !app.data.prefs.templates.is_empty() || app .data .current_ws() .map(|w| !w.config.terminal_templates.is_empty()) .unwrap_or(false); - if has_templates { + if force_split || has_templates { h_flex() .gap_0() .child( diff --git a/src-gpui/src/updater.rs b/src-gpui/src/updater.rs index 3e79774a..12cf591e 100644 --- a/src-gpui/src/updater.rs +++ b/src-gpui/src/updater.rs @@ -14,6 +14,7 @@ pub struct UpdateInfo { pub available: bool, pub prerelease: bool, pub html_url: String, + pub released_at: Option, } #[derive(Debug, Deserialize)] @@ -26,6 +27,8 @@ struct GithubRelease { #[serde(default)] html_url: String, #[serde(default)] + published_at: Option, + #[serde(default)] assets: Vec, } @@ -82,9 +85,19 @@ pub fn check_for_update(accept_beta: bool) -> Result { } else { release.html_url.clone() }, + released_at: release.published_at.as_deref().and_then(format_release_date), }) } +fn format_release_date(raw: &str) -> Option { + let date = raw.get(..10)?; + if date.as_bytes().get(4) == Some(&b'-') && date.as_bytes().get(7) == Some(&b'-') { + Some(date.to_string()) + } else { + None + } +} + fn pick_release(releases: &[GithubRelease], accept_beta: bool) -> Option<&GithubRelease> { if accept_beta { if let Some(beta) = releases.iter().find(|release| { @@ -253,6 +266,7 @@ mod tests { draft: false, prerelease: false, html_url: String::new(), + published_at: None, assets: Vec::new(), }, GithubRelease { @@ -260,10 +274,20 @@ mod tests { draft: false, prerelease: true, html_url: String::new(), + published_at: None, assets: Vec::new(), }, ]; assert_eq!(pick_release(&releases, true).unwrap().tag_name, "v3.2.0-beta.1"); assert_eq!(pick_release(&releases, false).unwrap().tag_name, "v3.1.0"); } + + #[test] + fn formats_github_release_date() { + assert_eq!( + format_release_date("2026-04-09T12:00:00Z").as_deref(), + Some("2026-04-09") + ); + assert_eq!(format_release_date("not-a-date"), None); + } } From 8169a88bee135a4281a2094f7df52d1b3fdd79d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 07:07:38 +0000 Subject: [PATCH 22/50] Surface remaining GPUI inventory copy and PR check errors. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add worktree/template/notes/path strings from the Tauri UI, show an empty-profile hint in the sidebar, and keep a failed PR-status tooltip instead of treating every miss as “no pull request”. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 15 ++++++- src-gpui/src/state.rs | 1 + src-gpui/src/ui/dialogs.rs | 1 + src-gpui/src/ui/file_viewer.rs | 80 +++++++++++++++++++--------------- src-gpui/src/ui/notes.rs | 2 +- src-gpui/src/ui/settings.rs | 1 + src-gpui/src/ui/sidebar.rs | 10 +++++ src-gpui/src/ui/terminal.rs | 6 ++- src-gpui/src/ui/workspace.rs | 9 +++- 9 files changed, 86 insertions(+), 39 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 1fb4eac3..2925298d 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -195,6 +195,9 @@ impl AppView { inputs.review_comment.update(cx, |s, cx| { s.set_placeholder(i18n::t(locale, "gitReviewCommentPlaceholder"), window, cx); }); + inputs.worktree.update(cx, |s, cx| { + s.set_placeholder(i18n::t(locale, "projectWorktreeDirPlaceholder"), window, cx); + }); inputs.default_worktree.update(cx, |s, cx| { s.set_placeholder(i18n::t(locale, "defaultWorktreeDirPlaceholder"), window, cx); s.set_value(prefs.worktree_dir.clone(), window, cx); @@ -382,6 +385,7 @@ impl AppView { notes: profile.notes.clone(), notes_status: NotesStatus::Saved, pr: None, + pr_error: None, avatar: self.backend.github_avatar(project_id), config, }, @@ -401,7 +405,16 @@ impl AppView { .map(|e| (e.path, e.status)) .collect(); ws.git_included = ws.git_files.iter().map(|(p, _)| p.clone()).collect(); - ws.pr = self.backend.pr_status(&ws.worktree, Some(&ws.branch)).ok().flatten(); + match self.backend.pr_status(&ws.worktree, Some(&ws.branch)) { + Ok(pr) => { + ws.pr = pr; + ws.pr_error = None; + } + Err(err) => { + ws.pr = None; + ws.pr_error = Some(err.to_string()); + } + } if ws.is_default { if let Ok(branch) = self.backend.git_branch(&ws.worktree) { ws.branch = branch; diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 997ad90d..cf963c77 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -329,6 +329,7 @@ pub struct Workspace { pub notes: String, pub notes_status: NotesStatus, pub pr: Option, + pub pr_error: Option, pub avatar: Option, pub config: ProjectConfig, } diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index f2da6913..56c80431 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -337,6 +337,7 @@ fn dialog_body( .danger() .xsmall() .label(app.t("delete")) + .tooltip(app.t("deleteTerminalTemplate")) .on_click({ let view = view.clone(); move |_, _, cx| { diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 44d83633..2afda3fc 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -81,7 +81,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) "| A | B |\n| --- | --- |\n| | |\n", "", &view, - &app.t("notesTableMenu"), + &app.t("notesInsertTable"), )) .child(md_btn("md-hr", "—", "---\n", "", &view, &app.t("notesInsertDivider"))) .child( @@ -110,7 +110,9 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }), ), ) - .when(slash.starts_with('/'), |el| el.child(slash_menu(&slash, &view))) + .when(slash.starts_with('/'), |el| { + el.child(slash_menu(&slash, &view, app.data.locale)) + }) .child( h_flex() .flex_1() @@ -420,9 +422,9 @@ fn checkerboard() -> impl IntoElement { })) } -fn slash_menu(query: &str, view: &gpui::Entity) -> impl IntoElement { +fn slash_menu(query: &str, view: &gpui::Entity, locale: crate::i18n::Locale) -> impl IntoElement { let q = query.trim_start_matches('/').to_ascii_lowercase(); - let items: [(&str, &str, &str); 8] = [ + let items: [(&str, &str, &str); 9] = [ ("h1", "# ", ""), ("h2", "## ", ""), ("h3", "### ", ""), @@ -430,47 +432,55 @@ fn slash_menu(query: &str, view: &gpui::Entity) -> impl IntoElement { ("quote", "> ", ""), ("code", "```\n", "\n```"), ("link", "[", "](url)"), + ("table", "| A | B |\n| --- | --- |\n| | |\n", ""), ("hr", "---\n", ""), ]; - h_flex() + v_flex() .id("md-slash") .px_2() .py_1() .gap_1() .w(px(180.)) .max_h(px(280.)) - .flex_wrap() - .children( - items - .into_iter() - .filter(|(name, _, _)| q.is_empty() || name.contains(&q)) - .map(|(name, prefix, suffix)| { - let view = view.clone(); - Button::new(crate::ui::eid(format!("slash-{name}"))) - .ghost() - .xsmall() - .label(format!("/{name}")) - .on_click(move |_, window, cx| { - view.update(cx, |app, cx| { - let text = app.inputs.file_editor.read(cx).value().to_string(); - let mut lines: Vec<&str> = text.lines().collect(); - if let Some(last) = lines.last_mut() { - if last.starts_with('/') { - *last = ""; + .child( + div() + .text_xs() + .text_color(gpui::hsla(0., 0., 0.5, 1.)) + .child(crate::i18n::t(locale, "notesCommandMenu")), + ) + .child( + h_flex().gap_1().flex_wrap().children( + items + .into_iter() + .filter(|(name, _, _)| q.is_empty() || name.contains(&q)) + .map(|(name, prefix, suffix)| { + let view = view.clone(); + Button::new(crate::ui::eid(format!("slash-{name}"))) + .ghost() + .xsmall() + .label(format!("/{name}")) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + let text = app.inputs.file_editor.read(cx).value().to_string(); + let mut lines: Vec<&str> = text.lines().collect(); + if let Some(last) = lines.last_mut() { + if last.starts_with('/') { + *last = ""; + } } - } - let mut next = lines.join("\n"); - if !next.is_empty() && !next.ends_with('\n') { - next.push('\n'); - } - next.push_str(prefix); - next.push_str(suffix); - app.inputs.file_editor.update(cx, |s, cx| { - s.set_value(next, window, cx); + let mut next = lines.join("\n"); + if !next.is_empty() && !next.ends_with('\n') { + next.push('\n'); + } + next.push_str(prefix); + next.push_str(suffix); + app.inputs.file_editor.update(cx, |s, cx| { + s.set_value(next, window, cx); + }); }); - }); - }) - }), + }) + }), + ), ) } diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs index bbc0b815..f0974df5 100644 --- a/src-gpui/src/ui/notes.rs +++ b/src-gpui/src/ui/notes.rs @@ -72,7 +72,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) "| A | B |\n| --- | --- |\n| | |\n", "", &view, - &app.t("notesTableMenu"), + &app.t("notesInsertTable"), )) .child(note_md("n-hr", "—", "---\n", "", &view, &app.t("notesInsertDivider"))), ) diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 020cd045..1466533c 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -756,6 +756,7 @@ impl SettingsView { .danger() .xsmall() .label(self.t("delete")) + .tooltip(self.t("deleteTerminalTemplate")) .on_click({ let view = view.clone(); let id = t.id.clone(); diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index 97ac5c9b..d6577a9c 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -774,6 +774,16 @@ fn project_row( v_flex() .pl_6() .gap_0() + .when(project.profiles.is_empty(), |el| { + el.child( + div() + .px_2() + .py_1() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("noProfiles")), + ) + }) .children(project.profiles.iter().map(|profile| { let pid = profile.id.clone(); let proj = project.id.clone(); diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index 98e3828a..7230bab5 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -211,7 +211,11 @@ pub fn render( let view = view.clone(); Button::new(crate::ui::eid(format!("pty-link-{label}"))) .xsmall() - .label(label) + .label(label.clone()) + .tooltip(match &token { + crate::detector::Clickable::Path(_) => app.t("terminalFilePathTooltip"), + crate::detector::Clickable::Url(_) => app.t("terminalOpenLink"), + }) .on_click(move |ev, window, cx| { let token = token.clone(); let skip = ev.modifiers().platform || ev.modifiers().control; diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 268488cd..ed816682 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -43,6 +43,7 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> }; let worktree = ws.worktree.clone(); let pr = ws.pr.clone(); + let pr_error = ws.pr_error.clone(); let collapsed = app.data.prefs.sidebar_collapsed; let pad_left = if cfg!(target_os = "macos") && collapsed { px(84.) @@ -147,7 +148,11 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> .child( h_flex() .gap_1() - .children(controls.into_iter().map(|id| topbar_control(app, &id, pr.as_ref(), cx))) + .children( + controls + .into_iter() + .map(|id| topbar_control(app, &id, pr.as_ref(), pr_error.as_deref(), cx)), + ) .child( Button::new("project-settings") .small() @@ -277,6 +282,7 @@ fn topbar_control( app: &AppView, id: &str, pr: Option<&model::project::GitPullRequestStatus>, + pr_error: Option<&str>, cx: &mut Context, ) -> impl IntoElement { let view = cx.entity(); @@ -319,6 +325,7 @@ fn topbar_control( .into_any_element(), "pr-status" => { let (label, tip) = match pr { + None if pr_error.is_some() => (app.t("topbarPrNoPr"), app.t("topbarPrCheckFailedDescription")), None => (app.t("topbarPrNoPr"), app.t("topbarPrNoPrTooltip")), Some(p) if p.is_draft => ( app.t("topbarPrDraft"), From 9d3cc3e8668aaea61dc8c3a97237e0a4d9dff894 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 07:13:50 +0000 Subject: [PATCH 23/50] Feed GPUI debug logs and make release CI ship GPUI. Attach the domain tracing layer to the debug panel so the wrench is not an empty shell, gate releases on GPUI check instead of Tauri smoke, and upload native 2code binaries as the product artifacts. Co-authored-by: AkaraChen --- .github/workflows/gpui-check.yml | 43 ++++++++++++++++ .github/workflows/release.yml | 83 +++++++++++++++++++++++++++++-- .github/workflows/tauri-smoke.yml | 8 +-- .github/workflows/unit-tests.yml | 38 +------------- README.md | 10 ++-- justfile | 1 + package.json | 2 + src-gpui/src/app.rs | 13 +++++ src-gpui/src/backend.rs | 66 +++++++++++++++++++++++- src-gpui/src/main.rs | 15 +++--- src-gpui/src/state.rs | 3 +- src-gpui/src/ui/debug.rs | 42 +++++++++++++--- 12 files changed, 258 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/gpui-check.yml diff --git a/.github/workflows/gpui-check.yml b/.github/workflows/gpui-check.yml new file mode 100644 index 00000000..6ce69e42 --- /dev/null +++ b/.github/workflows/gpui-check.yml @@ -0,0 +1,43 @@ +name: GPUI Check + +on: + workflow_call: + +jobs: + gpui: + name: GPUI + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Linux dependencies + run: | + sudo apt update + sudo apt install -y \ + build-essential \ + pkg-config \ + libssl-dev \ + libsqlite3-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libwayland-dev \ + libvulkan-dev \ + libx11-dev \ + libxcursor-dev \ + libxi-dev \ + libxrandr-dev + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + workspaces: | + src-gpui -> target + + - name: Check GPUI crate + run: cargo check --tests --manifest-path src-gpui/Cargo.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cbf21c49..b88dc1b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,11 +13,88 @@ on: jobs: smoke: - name: Linux Smoke Gate - uses: ./.github/workflows/tauri-smoke.yml + name: GPUI Gate + uses: ./.github/workflows/gpui-check.yml + + gpui-binaries: + name: GPUI binary (${{ matrix.name }}) + needs: smoke + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - name: linux-x64 + os: ubuntu-24.04 + target: "" + bin: 2code + - name: macos-arm64 + os: macos-15 + target: aarch64-apple-darwin + bin: 2code + - name: windows-x64 + os: windows-latest + target: "" + bin: 2code.exe + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Linux dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + pkg-config \ + libssl-dev \ + libsqlite3-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libwayland-dev \ + libvulkan-dev \ + libx11-dev \ + libxcursor-dev \ + libxi-dev \ + libxrandr-dev + + - name: Setup Rust + if: matrix.target == '' + uses: dtolnay/rust-toolchain@stable + + - name: Setup Rust with target + if: matrix.target != '' + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache Rust artifacts + uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + workspaces: | + src-gpui -> target + + - name: Build GPUI app + shell: bash + run: | + if [ -n "${{ matrix.target }}" ]; then + cargo build --release --manifest-path src-gpui/Cargo.toml --target "${{ matrix.target }}" + echo "GPUI_BIN=src-gpui/target/${{ matrix.target }}/release/${{ matrix.bin }}" >> "$GITHUB_ENV" + else + cargo build --release --manifest-path src-gpui/Cargo.toml + echo "GPUI_BIN=src-gpui/target/release/${{ matrix.bin }}" >> "$GITHUB_ENV" + fi + + - name: Upload GPUI binary + uses: actions/upload-artifact@v4 + with: + name: 2code-gpui-${{ matrix.name }} + path: ${{ env.GPUI_BIN }} + if-no-files-found: error publish: - name: Publish (${{ matrix.name }}) + name: Legacy Tauri publish (${{ matrix.name }}) needs: smoke runs-on: ${{ matrix.platform }} permissions: diff --git a/.github/workflows/tauri-smoke.yml b/.github/workflows/tauri-smoke.yml index d483e118..29106c0d 100644 --- a/.github/workflows/tauri-smoke.yml +++ b/.github/workflows/tauri-smoke.yml @@ -2,13 +2,7 @@ name: Tauri Smoke on: workflow_call: - push: - branches: - - dev - - "codex/**" - pull_request: - branches: - - dev + workflow_dispatch: jobs: smoke: diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 98d5c00c..a47cc7bf 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -11,7 +11,7 @@ on: jobs: frontend: - name: Frontend + name: Legacy frontend runs-on: ubuntu-24.04 steps: @@ -105,38 +105,4 @@ jobs: gpui: name: GPUI check - runs-on: ubuntu-24.04 - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install Linux dependencies - run: | - sudo apt update - sudo apt install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - libsqlite3-dev \ - libxkbcommon-dev \ - libxkbcommon-x11-dev \ - libwayland-dev \ - libvulkan-dev \ - libx11-dev \ - libxcursor-dev \ - libxi-dev \ - libxrandr-dev - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust artifacts - uses: Swatinem/rust-cache@v2 - with: - cache-bin: false - workspaces: | - src-gpui -> target - - - name: Check GPUI crate - run: cargo check --tests --manifest-path src-gpui/Cargo.toml + uses: ./.github/workflows/gpui-check.yml diff --git a/README.md b/README.md index 3f8b358d..7068a236 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ AI-assisted development creates more parallel state than a normal coding session - **Domain backend**: Rust workspace crates in `src-tauri/crates/` (model, repo, service, infra) - **Database**: SQLite via Diesel migrations - **Terminal runtime**: integrated PTY service + vt100 -- **Legacy reference shell**: Tauri 2 + React 19 (kept in `src/` / `src-tauri/` while the GPUI rewrite lands) +- **Leftover reference shell**: Tauri 2 + React 19 in `src/` / `src-tauri/` — not the shipping product UI. Domain crates under `src-tauri/crates/` are still used by GPUI. ## Getting Started @@ -66,11 +66,11 @@ cd src-gpui && cargo run The GPUI app uses the same SQLite database as the old Tauri shell: `$XDG_DATA_HOME/com.akrc.code/app.db` (or the platform equivalent). Preferences live in `gpui-prefs.json` next to the DB. -### Legacy Tauri + React shell +### Leftover Tauri + React shell ```bash bun install -bun tauri dev +bun run legacy:tauri:dev ``` ### Build @@ -79,7 +79,7 @@ bun tauri dev cd src-gpui && cargo build --release ``` -The legacy Tauri bundle is still `bun tauri build`. +Release CI builds GPUI binaries (`2code`) for Linux, macOS, and Windows. The leftover Tauri bundle is `bun run legacy:tauri:build`. ## Useful Commands @@ -91,7 +91,7 @@ The legacy Tauri bundle is still `bun tauri build`. | `cd src-tauri && cargo test` | Run Rust domain tests | | `just fmt` | Format TypeScript and Rust | | `just coverage` | Generate Rust coverage report | -| `bun tauri dev` | Run the legacy Tauri + React shell | +| `bun run legacy:tauri:dev` | Run the leftover Tauri + React shell | ## Project Structure diff --git a/justfile b/justfile index 7653efeb..c61c2ed9 100644 --- a/justfile +++ b/justfile @@ -27,6 +27,7 @@ test-all: just test-rust verify: + just gpui-check bun run lint:check bun run typecheck bun run test diff --git a/package.json b/package.json index 54cac50e..397dc1c4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "tauri": "tauri", "start": "just gpui", "tauri:dev": "tauri dev", + "legacy:tauri:dev": "tauri dev", + "legacy:tauri:build": "tauri build", "lint": "eslint --fix", "lint:check": "eslint . --max-warnings=0", "test": "bun run paraglide:compile && vitest run", diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 2925298d..5e7e10a7 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -785,10 +785,23 @@ impl AppView { let _ = exits.len(); } self.drain_watch_events(); + self.drain_debug_logs(); self.autosave_notes(cx); self.sync_file_draft(cx); } + fn drain_debug_logs(&mut self) { + let incoming = self.backend.take_debug_logs(); + if incoming.is_empty() { + return; + } + self.data.overlay.debug_logs.extend(incoming); + if self.data.overlay.debug_logs.len() > crate::backend::DEBUG_LOG_CAP { + let extra = self.data.overlay.debug_logs.len() - crate::backend::DEBUG_LOG_CAP; + self.data.overlay.debug_logs.drain(0..extra); + } + } + fn notify_agent_waiting(&self) { if !self.data.prefs.notifications { return; diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index 432500d2..a3f79863 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -19,6 +19,9 @@ use service::{PtyEventEmitter, WatchEventSender}; pub type TerminalBuffers = Arc>>>; pub type TerminalExits = Arc>>; pub type WatchInbox = Arc>>; +pub type DebugInbox = Arc>>; + +pub const DEBUG_LOG_CAP: usize = 1000; pub struct GpuiPtyEmitter { buffers: TerminalBuffers, @@ -72,10 +75,11 @@ pub struct Backend { pub buffers: TerminalBuffers, pub exits: TerminalExits, pub watch_events: WatchInbox, + pub debug_logs: DebugInbox, } impl Backend { - pub fn init() -> Result { + pub fn init(log_handle: infra::logger::ChannelLayerHandle) -> Result { let app_data_dir = app_data_dir(); std::fs::create_dir_all(&app_data_dir).map_err(|e| format!("create app data dir: {e}"))?; @@ -87,6 +91,21 @@ impl Backend { let buffers = Arc::new(Mutex::new(HashMap::new())); let exits = Arc::new(Mutex::new(Vec::new())); let watch_events = Arc::new(Mutex::new(Vec::new())); + let debug_logs = Arc::new(Mutex::new(Vec::new())); + { + let inbox = debug_logs.clone(); + log_handle.attach(move |entry| { + if let Ok(mut queue) = inbox.lock() { + queue.push(entry); + if queue.len() > DEBUG_LOG_CAP * 2 { + let extra = queue.len() - DEBUG_LOG_CAP; + queue.drain(0..extra); + } + } + true + }); + } + tracing::info!(target: "pty", "startup: marked orphaned sessions closed"); service::watcher::start( Box::new(GpuiWatchSender { events: watch_events.clone(), @@ -105,6 +124,7 @@ impl Backend { buffers, exits, watch_events, + debug_logs, }) } @@ -497,6 +517,14 @@ impl Backend { service::filesystem::resolve_terminal_file_path(&self.db, profile_id, path) } + pub fn take_debug_logs(&self) -> Vec { + self.debug_logs + .lock() + .ok() + .map(|mut q| q.drain(..).collect()) + .unwrap_or_default() + } + pub fn take_output(&self, session_id: &str) -> Vec { self.buffers .lock() @@ -669,6 +697,25 @@ pub fn language_from_path(path: &str) -> &'static str { } } +pub fn format_debug_time(timestamp_ms: u64) -> String { + let total_secs = timestamp_ms / 1000; + let ms = timestamp_ms % 1000; + let s = total_secs % 60; + let m = (total_secs / 60) % 60; + let h = (total_secs / 3600) % 24; + format!("{h:02}:{m:02}:{s:02}.{ms:03}") +} + +pub fn format_debug_log(entry: &model::debug::LogEntry) -> String { + format!( + "{} {} {} {}", + format_debug_time(entry.timestamp), + entry.level, + entry.source, + entry.message + ) +} + pub fn file_name(path: &str) -> String { Path::new(path) .file_name() @@ -701,4 +748,21 @@ mod tests { fn rasterize_missing_pdf_is_none() { assert!(rasterize_pdf_preview("/no/such/file.pdf").is_none()); } + + #[test] + fn formats_debug_clock_in_utc() { + assert_eq!(format_debug_time(0), "00:00:00.000"); + assert_eq!(format_debug_time(3_661_123), "01:01:01.123"); + } + + #[test] + fn formats_debug_log_row() { + let line = format_debug_log(&model::debug::LogEntry { + timestamp: 1_000, + level: "INFO".into(), + source: "pty".into(), + message: "ready".into(), + }); + assert_eq!(line, "00:00:01.000 INFO pty ready"); + } } diff --git a/src-gpui/src/main.rs b/src-gpui/src/main.rs index c8ca4a63..cd191bec 100644 --- a/src-gpui/src/main.rs +++ b/src-gpui/src/main.rs @@ -11,19 +11,22 @@ mod updater; use gpui::{point, prelude::*, px, size, Application, Bounds, TitlebarOptions, WindowBounds, WindowOptions}; use gpui_component::Root; +use tracing_subscriber::prelude::*; use crate::app::AppView; use crate::backend::Backend; fn main() { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) + let (log_layer, log_handle) = infra::logger::ChannelLayer::new(); + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer()) + .with(log_layer) .init(); - let backend = match Backend::init() { + let backend = match Backend::init(log_handle) { Ok(b) => b, Err(err) => { eprintln!("failed to init 2code backend: {err}"); diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index cf963c77..353c1cbf 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -1,6 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::time::Instant; +use model::debug::LogEntry; use model::filesystem::FileSearchResult; use model::profile::Profile; use model::project::{ @@ -403,7 +404,7 @@ pub struct OverlayState { pub settings_open: bool, pub settings_tab: SettingsTab, pub debug_open: bool, - pub debug_logs: Vec, + pub debug_logs: Vec, pub expanded_projects: HashSet, pub pending_close_file: Option, pub editing_template: Option, diff --git a/src-gpui/src/ui/debug.rs b/src-gpui/src/ui/debug.rs index 77a2cb75..4ebd81bc 100644 --- a/src-gpui/src/ui/debug.rs +++ b/src-gpui/src/ui/debug.rs @@ -55,13 +55,19 @@ pub fn render_panel(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); + let theme = cx.theme().clone(); let q = app.inputs.debug_search.read(cx).value().to_string(); let logs: Vec<_> = app .data .overlay .debug_logs .iter() - .filter(|l| q.is_empty() || l.to_ascii_lowercase().contains(&q.to_ascii_lowercase())) + .filter(|l| { + q.is_empty() + || crate::backend::format_debug_log(l) + .to_ascii_lowercase() + .contains(&q.to_ascii_lowercase()) + }) .cloned() .collect(); v_flex() @@ -89,17 +95,39 @@ fn debug_body(app: &mut AppView, cx: &mut Context) -> impl IntoElement ), ) .child(if logs.is_empty() { - div() - .flex_1() - .p_4() - .child(app.t("debugNoLogs")) - .into_any_element() + div().flex_1().p_4().child(app.t("debugNoLogs")).into_any_element() } else { v_flex() .flex_1() .p_2() .children(logs.into_iter().map(|l| { - div().text_xs().font_family("monospace").child(l) + let level_color = match l.level.as_str() { + "ERROR" => gpui::rgb(0xef4444), + "WARN" => gpui::rgb(0xf59e0b), + _ => gpui::rgb(0x22c55e), + }; + h_flex() + .gap_2() + .child( + div() + .text_xs() + .font_family("monospace") + .text_color(theme.muted_foreground) + .child(crate::backend::format_debug_time(l.timestamp)), + ) + .child( + div() + .text_xs() + .font_family("monospace") + .text_color(level_color) + .child(l.level.clone()), + ) + .child( + div() + .text_xs() + .font_family("monospace") + .child(format!("{} {}", l.source, l.message)), + ) })) .into_any_element() }) From ba5e6f2396e06a87dc99bc9f2b4ac5b53c6a1be9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 07:24:03 +0000 Subject: [PATCH 24/50] Publish GPUI binaries and match more inventory chrome. Tagged GitHub releases now attach the GPUI linux/macOS/Windows binaries instead of a leftover Tauri bundle. Settings top-bar controls can be dragged to reorder or dropped onto Available to remove, Reset uses the shared defaults, About shows the app icon and maintainer avatar, and terminal tabs take their left icon from title keywords with a pulsing running-status dot. Co-authored-by: AkaraChen --- .github/workflows/release.yml | 136 ++++++++-------------------------- README.md | 2 +- src-gpui/assets/app-icon.png | Bin 0 -> 29186 bytes src-gpui/src/app.rs | 7 ++ src-gpui/src/detector/mod.rs | 1 + src-gpui/src/prefs.rs | 47 ++++++++++-- src-gpui/src/state.rs | 37 +++++++++ src-gpui/src/ui/mod.rs | 5 ++ src-gpui/src/ui/settings.rs | 111 +++++++++++++++++++++++---- src-gpui/src/ui/sidebar.rs | 22 ++++-- src-gpui/src/ui/workspace.rs | 15 +++- src-gpui/src/updater.rs | 22 +++++- 12 files changed, 265 insertions(+), 140 deletions(-) create mode 100644 src-gpui/assets/app-icon.png diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b88dc1b8..cd9a1af5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,119 +94,41 @@ jobs: if-no-files-found: error publish: - name: Legacy Tauri publish (${{ matrix.name }}) - needs: smoke - runs-on: ${{ matrix.platform }} + name: Publish GPUI + needs: gpui-binaries + runs-on: ubuntu-24.04 permissions: contents: write - strategy: - fail-fast: false - matrix: - include: - # Build macOS packages on native CPU runners so app architectures stay aligned. - - name: macOS arm64 - platform: macos-15 - rustTargets: aarch64-apple-darwin - args: --target aarch64-apple-darwin - - name: macOS x64 - platform: macos-15-intel - rustTargets: x86_64-apple-darwin - args: --target x86_64-apple-darwin - - name: Linux x64 - platform: ubuntu-24.04 - rustTargets: "" - args: "" - - name: Windows x64 - platform: windows-latest - rustTargets: "" - args: --bundles nsis - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Install Linux dependencies - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - build-essential \ - curl \ - wget \ - file \ - just \ - libssl-dev \ - libayatana-appindicator3-dev \ - librsvg2-dev \ - patchelf - - - name: Install just (macOS) - if: runner.os == 'macOS' - run: brew install just - - - name: Install just (Windows) - if: runner.os == 'Windows' - run: choco install just -y - - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable + - name: Download GPUI binaries + uses: actions/download-artifact@v4 with: - targets: ${{ matrix.rustTargets }} - - - name: Cache Rust artifacts - uses: Swatinem/rust-cache@v2 - with: - cache-bin: false - workspaces: | - src-tauri -> target + path: dist - - name: Install app dependencies - run: bun install --frozen-lockfile - - - name: Install Apple Developer ID G2 certificate - if: runner.os == 'macOS' - shell: bash - run: | - curl -fsSL https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer -o "$RUNNER_TEMP/DeveloperIDG2CA.cer" - security import "$RUNNER_TEMP/DeveloperIDG2CA.cer" -k "$HOME/Library/Keychains/login.keychain-db" - security find-identity -v -p codesigning - - - name: Prepare Apple notarization key - if: runner.os == 'macOS' - shell: bash - env: - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + - name: Stage release assets run: | - mkdir -p "$RUNNER_TEMP/appstoreconnect/private_keys" - key_path="$RUNNER_TEMP/appstoreconnect/private_keys/AuthKey_${APPLE_API_KEY}.p8" - printf '%s' "$APPLE_API_KEY_P8" > "$key_path" - chmod 600 "$key_path" - echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV" + mkdir -p release-assets + if [ -f dist/2code-gpui-linux-x64/2code ]; then + cp dist/2code-gpui-linux-x64/2code release-assets/2code-linux-x64 + fi + if [ -f dist/2code-gpui-macos-arm64/2code ]; then + cp dist/2code-gpui-macos-arm64/2code release-assets/2code-macos-arm64 + fi + if [ -f dist/2code-gpui-windows-x64/2code.exe ]; then + cp dist/2code-gpui-windows-x64/2code.exe release-assets/2code-windows-x64.exe + fi + ls -la release-assets + test -n "$(ls -A release-assets)" - - name: Build and publish - uses: tauri-apps/tauri-action@v0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} - APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} - APPLE_API_KEY_PATH: ${{ env.APPLE_API_KEY_PATH }} + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 with: - tauriScript: bun tauri - tagName: v__VERSION__ - releaseName: 2code v__VERSION__ - releaseBody: See the assets to download this version and install. - releaseDraft: false + files: release-assets/* + generate_release_notes: true prerelease: ${{ github.event_name == 'workflow_dispatch' && inputs.prerelease || contains(github.ref_name, '-') }} - generateReleaseNotes: true - includeUpdaterJson: true - retryAttempts: 2 - args: ${{ matrix.args }} + body: | + Native GPUI 2code builds. Download the binary for your platform and run it. + + - `2code-linux-x64` + - `2code-macos-arm64` + - `2code-windows-x64.exe` diff --git a/README.md b/README.md index 7068a236..0c6fe407 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ bun run legacy:tauri:dev cd src-gpui && cargo build --release ``` -Release CI builds GPUI binaries (`2code`) for Linux, macOS, and Windows. The leftover Tauri bundle is `bun run legacy:tauri:build`. +Tagged releases publish GPUI binaries (`2code-linux-x64`, `2code-macos-arm64`, `2code-windows-x64.exe`). The leftover Tauri bundle is only `bun run legacy:tauri:build`. ## Useful Commands diff --git a/src-gpui/assets/app-icon.png b/src-gpui/assets/app-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9486f6903ef653c2b863b031f0801f2f994704b0 GIT binary patch literal 29186 zcmbq)Wm_E0^Yt!^ySo!S0fPH3?(PXrNFXG^LU4Am;1=B7T@u_OXz&|%cVBE5c=-Ll z!t-jTubS$v>8a_y>YUSI+L}su*wokn002)#SzZ?a0KVJ;0hs777gLv#j{t!4kczyF zzQ^K8yJrgRe~G*vj@!BhWn3o`i-e1W@8*LbD5&VKYHj#DdRP9muBAJ+NKHQ$A>blc z?Ry7~%PY^ zKs6t9Aw^Gbm1K+ESl3Z*EYpW=sXWIM;uQ12$Kq&MKwHkkykFJoIv7wlc(#L-++N!o z%jB+G_)>wZxvBh$`14D%FvyLgvz)&ji_9CqVzfWvmm%bk6ht_lA*R>x`?r{f%W{*m zi=}1P?W&oX8CU$!U~dS)Rgu*9{n-8e{Xr8NmHyMc*bad+=cxjfvF)LFlT^(NQJq)u zDndVwjn^Kvil@u!>*LS9YH4YG8_$wLN*%AZzbCuLSCxwPuw_*0c3Ys~y4<4@_$V4d z{>CNJNiGNcbZ6sszTRE23ZN3NR}c=&*D7V$Fe|KT+6)(Lcl?#0RM*_Rko!*p<;*i1 z?&`XJn@g9ft_z35ca%c~7<{jFc1w%1sB?-j;Ey8MLdD}HXVST6of-VLs@5Al!Nl0T z99Uj!%d73)|GXDu8Q@o;2Bckka1OngjJpS9($Z2Scgnf&`XSvR`4-b@YfhJEVV8%MKHA15-w zZ(Q^hhaZmeASvkyV5QZ(#r^w7?|KuV95N>cskuu1(|yP@#GNhuqDuq z2b|y8Yl?|PCMWFoL}kKcEQPc(;iBGeWWL=Zok-c zK^tNozf@10MdAkV#eRN7Qnv36m&^S)JGT=X z)}4>|k{YtzR6j!*XQ~!DzVoU*+kE7HmJ8?Ch}Cj@nJlz*`8M$!{jNv?>H4JRqBw!$R-`0UKx9Qx>nDYZOsv9#)8^IyjORrM>8C+_W*Y@JxNp}3u#XZGfu72Iwz zzwV^qbkR{tjw7aRw=(+sH)TFl6mnftYHg0r*1?hUg;wKg2qjxVv_~y5{Dctws$YNS zQh%XM$gy3~2NR6@JRB6q=rlDZW=VwCoMgC;+2>C1-cG6U+A_BV{4_+Znz}MMGs;PSVY%ZpVFcqce0O&TLvp*? zMV6g6r9oZ@|UtyqMSL8P)CWu7?6u9hH6V3znl!=gyJ0r^;IldK&SX!%UIj$RdawJANTpAN>g2{aj zuzi1SORM25v`t_i1LGcMW7t zW^$;GTvvN08>n(=qu-%(DuIM|bsxYM3$JAOZDc_xIBQES@LO=xdO$>0eoM?nkccf7 z&7iSr&Z!)V$zG46o&z0<uek!4Jy2I~hM?uihVeg{cImW^!Mc=t9 zm~;X4;(^1wF(2|(a5)rat-=Ak!KB5;qa9`}6pd2cQUz2UskK`$a z;2}bFV}-_((77$QwbeMDvdG88Kw?JTcx&}zDKvT=SJ#oK`E>C8(6cC+H<`C^Q_PVL z)}R-~6Z~jS1bn|8Z-!cz(Q4KE6Juymi-acxyz}?r!wCk)UBuhdG=e=ng|6(o+q+vA zD1k>Dk8tb+go(y$F}{?PGwe}I@}du~0AQZqE|GNe%>qDDQtkv1hMl_;BMztjD>st6 zd+*kk{Q-kfDFAF|?Q23WB&68&@@u>csbvDBB|ria0fo5sjz#et@?JZM6pqIAyPKcv z31Z4Wu`(W+-C20~?cqlzUvw)`IpkvbR4w6$zX|@=gN-9bkj3nxuf|No`c}Pmw?bM=6E5K7`L9UF zg=n)O$@)!vdVdmFsVDm%E_KI`f@?J$S_Y$UEZ@!zEt~hw;r>@3o^VM@U*8vH4r*6Y`_Ao|FdsVdM&y{Rf$Ko?N0d z=V?w>J#b|3uCZ3`119m%D;pGa$^t*o8DRw6iDSnxpd)ZP4BAX* z^x_4vpLHNkJ0W)mdoqDHEW6{3x70}$QCBk;UUU`Q_P43p^la;**yvvfy&qQG8y|6Q znpTg(Vx4jh6&Fe|ZnMko(n@??_xwSKr+|kGUxWNdP7WmnDTeyM)F}aX{WkjQx$c%x z8x%1oJr`4k1Qe5!!a{iY%TkNLId(g~_v!+DsN8z89Z3?3`|phPW+p9yx5LBr>Bnx$ zVmi~iYF%e~adXEmw;6nXEyl1WSeAw>@&S1@Zu;ljiY@Qts$*{Znnhl?Qy(Wl>&|{% z?$^&xU1X|w`1sOF-==g|Dp{CFb$uT_e`)#jh1jWQK>|o&sBDaMi%SQn!S!c)a|ddK zyT5QhsG#!`sap000dn!AFwa5F#py-+$WNGH2v|gz`hyp55P0lr;3zNG6D6ExqA3@a zcfD?j?oH=S?`<&xG3H=Y3P-_2dn)l0SK#bi4WVywB(v{+?p4r|DJyCM=Qls<{`GwF zXz!jY#KHb737`d1x*y*oPFo$xJ^<>pxgis!afma*d5k#=Gw1$9@Jy)P&szO+L@(hp zr18$Wk^LoqkG^KM>ex2lzm^&)`LQ|7EnOk-$Lh+4c1obCuD{LNwSSmz+(afM=VxmA{5SYz(%;_OKlOB_ zpTM}JE6<0Z^ICZThFu9E2fH zE7uE%jfzYnGl}AZ81_EST}W%HoIZb?`oV45_;F%riZLdm=n>nXV3TV;PwXXYXuQlW zklpgb*dTO_#=@zC5xZs9_B!txYAc9P3T*6np*|NCUHI!_gSHXU{#S!Up$Pwv@T$Pk(KE^J;@(f`3bpi)3Q2V`5p*5HQ0^ z_EvN1l3DEfJRJ4x>YU{W{290z~`R>9= z0us;uGuykJdn~Eg=)%^Ijf;(x^P9*DQDD!742dmYjX(0Y zZ!#F9C>aoyoYhq09Nj8AcbjE?>tU0FFHyef8Sll zYxNIENR!Wiw64G3p8H^^qyxz5Nxs*$oUwGERN(2Vh+b};lNM=Z^Jq%BWf`|z0;05($_h*E zJ9h4ZiaiQAzSQM#wewU)PCHp~Gu<<+kmfzii9{Zu1)RO}A@^}Pi*a+u`Rw{;)niNJ z;t6pUaazT*+bZX@G*qUQE!`U>(JZDI9lYid@Do34x^wy**ZF*1rq*>&v)1!U!1{28 zb8}Q+ob*$+@1GO__bTOB;)62=%bhoLZ|+nNMlfBmDs-gXJfVr~5*iwBViOaRGIP$$ zz5b!hQ^A6y^}c@nYHy0^o2#Y$#hpA)R>{^B-IB*hbgTLhv+@YoloQ`x@pJxZA?$}j zpl==x@rIHAU-EDz%R&zGGbR}*Jkh9MU|gEz<_yeOGV^{knSu4&BN^(|g#mW|Xl z@O$ewaOOY^1G_`^MG*O0Kk>{>C{FMc|Hnv5Id&&dTT#T*-^qL>g>duw<49ZU$Deqc z4b!{zbFc|OzmOjR(`6I6@@vY|VsvQ1mOkc+rtYksCt{z1r(XB^pe?DPqR zD-6g8V&q&M`Sya~pu7bXFVVwd0Qwl7i-P^x_g!^^jV0KfQh+p&VAb+%bwOmPnpy$o zA%9FIcFQ7!kF%0H3wkPcakmu{0m8r!)%%#mJjR3*rFMKQ$@RncB3M*b7H1=vNYw8* z}*d^w0^X08F^s2IPjeKdo&8qo%1Z+#s*L z-RwF}SEcEqkY?(F9;X-M@al=X3fskubP#)J)VQTusR4ILu?v7*11K~#KB=f5`&`Id zxS}S(Vc`DmC8R&~YFJ0Tbnk}&?aO6Vv|ne*qjm6#WmLDK6@mJ!!d7OUz+fJ*Hd2ao zZOsos7m#Kg>bddxsxfV$!eX^POjxw8$^xbZDR{$^+Xj`8#RHlS8AU z=t*BtG{QFxlQHpp+<1nivD*S!3_1Tg-OtRO2k6{GhvL~H&~PYXiT7EwdM<=5 zn9&Cx{a6qpH^EXdh&&VT^UlY}T{AV3|dNmHF@V-4J!oWHRNzeW* zN)JI6{@F)ANsf*mr%ypjyQ1&w9G+|MB+8MiCQSu{1m9qX7pB()ebdE`XV)OsE7Bxu zzh3Tq>UE83x+2;7uF_dCvHY@?#3I!|VIa3E^V->~)KtEz0cYLyN_`H31*1l(c zk*>r*wpaCzH4O{t{oH=r_vsZf6fPmr+BShTmp(z~sBRk5z<|mS-Zcp;9k|OUxfSi5 z2SB7UzLgoHiC$^X0zildQ$C^|X}3keO@c^@+ckIabNfq0dUX|TC@sxOxEnR(wQkRW zWBS{cja{o?Put8A0B|~IVt;{Zhj>u1;l5>A1NeQHc2ml4**8^;>$Ifr<<(h^rW9s> zdi`;wUt-NOj>okNWN!aYtCpQiyq4W*>1$&c_&y$NE5G#5MKmjR#Cr614sl650<_`3 z1bz0s4OgP~Gp;aeO{zYkQ@!vT({ zd69-MolZ6y@wpE4_^QcwTLyjuudAv5r9G^~X;?seESU+cB$(=w&Nz**ELdva2cRxRL#Q6 z_peEDz=ew7)u*_-9bQLv;Z@xqTWhO--tZy`?P?wdPv`}T5-*=BUtfPE^dbJ-hH*-E ziev58cdUF8mCctW7tW%D@2U>n*ACfdB;c02rM`a_mPO_QVx>{@3C$^22k44y2BlRy zJ4c8tS=HBo34oqI-k8OA-Oo25e5^0QCkG1wh-%-z#k^(lj?<08s2bjj}mC;Fhawq`@d;N&5~=xf*Qcv zl(ya|2XTrn#6wQV5fFKQrDmEgzk7@|7sNI{zy-p(Cp3)fy&l_^2)bpc&-TCwDsSPM zk~5vrXEc952x0?^X)Gk2z&`|yK}qt7yzYsrLyPK3WG~CR%DBlAIB;My@aM{xZ=|z8#AiTsaCYEA3c#Cl_!NeLjlL89P2-rOd zqpH-FLlR1S z67fFCiyIM%DLv~t&Zw{lN+9TT|8i>}TEH01s+;9Cxv)r(wRpR*!YI#Yf5mj?fE=px zW0NLTNHGSBNN$LXpL#hox%@`k)*jmbL4O7733q2xv*G zzxqbCqfj{MXKiW|^^GlFpDC9jv=!=>s&x*no6r6cb^>pEaq?o%UmIOx(ndltkI3ewuDcS)5vF0bq#>$3?xt zm8d-tA!Tk*{j5lIZq552=S(R9YDsiGo!+;}1ry%$y}$_(O%fN#sQ-HR;qy8MluMXU ziQyK_N=wrlTX8~TBf1w`7@FqB5iFxY|L`!^$-~on%x{=QqC5$ZgP{l?$a7*Nj%2!J z4`l7mZ<+3@+b*%W`Q{LHURb21$O^wsfB`@=zH!PN@4muLPkReE;l8Y2p{h5NZ1JDLA1{+~*Uo8@%Z-c^`nTlhNOHU4&fq8;1F*r$ z{MW#(gr$G@;NOtnw0F|DO1{+SNTEi~`>`g5ZLaRJHyya_FvkPdG(OoGNzysI(&`I9 z!!bTrx39s)6vy!%bp2*hUCtPpvuVD1{vS>&-5D(^B=&C4jn!)9&uKGCR?taM{nRrIVY{;LGea z;~GoQc25BO1_OGIp{}8j5h2Q;3V>PsBK*qMh}r#?=W@zP&fmp-lI8VIeFD{dn`eDU zG>xu~v%9{*#zW$xLM>tU=bp-ev(_Ic%xE>qXxu8YsMY9mxVH?Pt<#(m=TaO>C_t(& zSC9@483H7}!|DDn^GC@9K!sROq(gQ4&u4rmzzhI#&{Zc<6e4GtL3`Rc!nzOVLIX3~ z?vl~p%kmhM$rfKC^n&#A4y0en2Wp^mP3RW1I~k}<&D-<7p|pK;SZS1g?VAf^Hm3dZ z4fd{a*#)Qg5vHj%X;_*GdHqH8%8TNa$ zVEI|kk)lCEp>g6aK52o)iXh<%#h|+Gh_=e?*Oe&rd|uTPz8MY}eKTM2*FuEE8zef>D`e<@s7=JMKs+uguX@7b+qRJ7V!YATF z`uP(`?+G1Yd1eC1$4#4mm1D=b1no_h^#ZP>JjY;39DrzT<)8SGvdpliEELRmxC`T- z@DnY20iY1(Ab)%Su1pe_(9j6gw{0+{L)A+M+B6INK|TA_JKepBu{F&Exv9 zO;y3ZyK|Ivez}sBgv3@)A}&TDv>0F#X)2JkYWc?!FK3bTs0O3@JN0{@|E8*CO7&Xb z@qmg|JA5`>e-0ckMd|Z#d*AAj$;atAmvViw!n9fTg}huSAoZ5|T+Kd%)^pl;niu-3 zMh>HmH(@-lKSK0oU`;_ZIQxoH&WbX2+FmKhL6i_50;FV)WpJmmMkK4AYT1U^XyS}$ zHh?E7DFOXsO`#tfnd~%^;0F$~bz59!xV;?umd&=Q@17!z`eo3z3*+qCQkk zD|Xw+h0CdbFlK-(1F4S|40PI7d!5D4@rC}xmm~kRdNS*T53ptIRUxWYeqviJn_&YM zFWJ8IcA^kT!~R+~4rmf{t})pu(v-*XG?AEHdL%~{Fu6I(a#4UXtn@+Uc?aOP(JAH z-Ns|Ae3@*=FvJ-YqGtd~_16_U+-Q2rL1@hd*O{BLTOVtFzW}0CPLz%8+{G}l0)l`1 zrBX{>{$Tn=g8i)iMxnA3Q|#pS?d%F(X3==c7!^7ulzTEd*RFlexX00B*3>)Jxg7gK zoI{{dIDcZ#h~3(d`}Z@gBU7cVlER?jN-^+0hmuDoU4kmyX66KWb3PL1mG4i(2l0IL z?8CbY1S3I;kGZ4}_e}N53YjpCL!aL=MYWzXl?tB;FOKDP$p^6|AFn@FWsH2R9EIUw zlaJtLypCbcL6y$M?+Cr9S06vgbLw|HOHZ3R22z%tH1AbiRzR~vh)%IEEkd&Rw`Jdn zQQ`z;x(&a+0d(Q^9m5hZS{{Ne-j%d0SWCP1B z?lwaZV-R{556qFVDofZmZ7y<}#AAcPC!5{!E%%6U2>FvWD!FI`kov+z{k`)z=)5jq zD!nQ9TAL^~S=(T~`~o{2R4{C>Wdx#!y_XYEznU$}0c=kg1eS3U1yGgk&aq^tS5|s+7)jB4P}>IRX5OE<<}KtU{Fjfj^iK&J zB4Z}?qfQc#57-x=m)uA3(+1e>MRn7it1{XZ6*0VE7|3Z+gq5l{&fiNkQ%@c3BgV>D znTJz*$|q}SmY`Lnl+)uvFTd4Kc!%zMZ;=@zv3b%08+S|*)g?X(u;7${Z^;|?yWyKJKLI*1s1qzfR|5M)yUrmoGD$m zTdod|tC{TD{hrv(JVkK*dC+K!Mud!SU-AeBGOhy$ySj*%TPvva4C_n^%n}TOAC2U( zP(Kt~rNSmf-UlP;P4dkH;hXAKywYebk$Cr+XUoG(0CDWM?wO>3{T{%7+RtODz@0yY zd!w)$)UIz!8j%u$i$RI}ZkUR9`8Xj73_V$U6?2{t$<%TP1q?{z!MM0&IHuFg~L5(M#4mydKgYTr&pZ3wf>8!>8clY5 zI`}e}2W&e=Yb{w52TA;2cPC44CY`EbUJ`WKJ>33qjUALI`CJc9q!z-?!Jk>JArsps z18&|+yQBkSebX2Ho!@^UPP3mW|29=HWC{WG$4}PAeqCLlqCG&{> z`IWLL$B5UG6U20!t#$ssW|w()uu^yf!}Lqe$=%ZVyI($R^DIE=7gUcze8D71;*Adn z!^B+uWWy!D8%UWTJcx~TsHU|C|!RkAOrb;E>Fj}X?%~l$+F`?q zdiTRbDoRZHMGj_sAe*qFDNx$OrIB4GkanM^m@t>;)RuJ|yS>whEMz(#gy5n`ragb-QCW&8ytzg4YKO*?Ofec0tDVA8tpX1>D9tK!42crK|_iyz)wqgJvPBVUrvy-JD z^NQ>0Da12;9j+z?CbYtCv>_43=+Z^;s-X1d4MiN-cJW?XS~eJ!^_X6UpH@$zqdf4K zhHZWr^|FZhh?@pXqcjN?Hp0Y+rOxOHmLs7u`DBe_9?_HNcinJg4I#Ac+xNRd!&`>@ zgHbhYl-u*-5O6%+Uzh;Tt&W9*KAx>gnL39^BM7NIfE+!|>j6Th1M9jD5l6S1j5`z3 z*;I@s-^pS~aS{cSdnDkB#8vErL}10j#>G4|hL}*uz3hR-Jdv9aQ9TK5UR`lN?AyEVaDPZ99O4|)4X2;b%i%@=Ab zdUh%ASk6$X6w@EXBe}^14g{u{;Bc)OG3Ks7Gd$=XC@nQXHk|z0X}rBSaJlzCW4c&O z1Qv9-xQt)V{?5an>%|~Aq=J0=2=mzAs}eV&%`rs;&V0Ed4KD;@_rO1S)f~Ri;LB;i zYutVHmJ99#v^tD^wxH}}9;rYX#nxtpjxGf+vhSWoZ5~w3b;dF0s1xfpH^&R~4)_}w zS&hM++E|18xUgUBPEZAzPWc3F7D0bPmT45nJ~?FKWkKpdxZVv4S*p6lEF5PG-U;`} zU;@ednDxlvx$N8|C*-r?8b%bwj8k0S4MsNJ&38kJK7~LB`I)FPSu6^ zMMT0Whzy;L*b3KED@Gy+b#`H#K-&)=NjOtm_ctGHyocOehLw?flPL{LVNf$CoY;_d zHXGP&I;MmmGnCT_(p=^RT!2^R^!75#WaLcvx6E5BOBK~#1|NoM?;7o@-ODbdhTYHi#+{GaX@e%087PJPeN;xkt! zw&L@HX)Ul#_fI#aj{)9~rN}|IUJxXmkI>IUP|U<{`6xJo+~qH8I@T<23FcVdWqdGK z$EUnJLR)J4D1F>6t#>+x>P2nUxwW$qE;b)JTJfm$Ugt&K!{2v|IZLY=Ov(Y8p@J*w zg;N>BztXf(kpOiW4`3*C14~jN6^Pp@So^gD3ab-kFPo_35y7w#xvpsga@v|a-}RkU zCqBEIT3+gB<7bZhKVFia(seb1e;vU03W7u#& zD^{c+fGDs*^~1KcAFD#TjM36#-cWtr{BN(C+{n!L_q>vNcZ24=vj|i8t^bE~t0XEZ zpN9GoJdE?`LEL5Uv-VBU;l_BcVqG%?EjK(xRDW41nd$ty@m5T3&ON)!vpO|de1J^Y zPwq91E;|BuE^6HKII~RxRQf7#8MQwNtR<49IyqW$h`Dh1n89ERg{qhh&}9b%8eG!~ zVv-Tw;L*%RT=6!+rcFyIGYVv6PP>y`*w=GDzI;Fq;79CV?3SOe&zfvVYHx3d&uxQ2 zJ-kTUzyKG!+TserpM~Ff!N%Wfm^Lo+wM!$;V{KA~EA$d}!AkFDJ4+Wver34bm;}*8 zUhMMTO$)#c3W^kQ$mU%G^rO70jT}~5<*5vX6WC8Ds#@=NtDTcq+FL#X%mS@0+QdM* z#=T0dQh(M(bt&(tQh^*80zM5nF)-{OgIHgFu=+^CnHa@(@cKAzpMp8cwCqB$LnR5F znx2(4urqH(@FBAAp-oRIjJPpaRDsjU8Je8`$vA#V7g6E(EMCEpts+)&RQBUt4mP$W zqt_KFjCs*oWrsKWb0540=?D-N3FKwmAH>bMLk|_f1e5$`R(2&zH$+)@EpiydxGt{@ zi`@`F89Hr7&^yK>cJUYc+5M4?T4EVa2j6NMPUO$BrC26ewSm`tvAW9yFsReBlzG5F zt&_0|^;~DF1I2^A-H_Pz8Jj@2Dk@}@5rbJYzxohS{PSzTXH`^eG}Qz&YoR+Yu~Pv; zXai=t@K2qh%O94vp|7(PJnz081J(xc6I z*FA$3(SdnSueJMhF9$Hb*1|+eK(>Y^Ot2E{zjJ-`|FiiV^zA)5Yxq-m(+^9Ia>Gg& znt);JuEN(FnEDRSp_96JqQiVJYv=ler*EjtK2jltTf zK;L#>b>aYu7Y5tlr0HMXWg#4R^OfX5`b2MD#DsnXAGoTFmLwvgmogfCg-2xMi1C`IccM4$mEb|ya-(dDZ$>N;`5!WuBZ$(`6Y}ivi%5Cvf&1<+_HF)|87dUSh?%{15EjLft>!>@u?qD1s4?>Cux=L*xPX{rn4km zrljoZ^XL8L?IEh}f6~ck#94QRf1z)cYF!a|RqsC+4EMqMQ!Mr8(18@Y>mfIt(5@Gq z^t%H(F+CooERYs(`ZfVz?3g*Tel;cm?Vom{U7481N9&miP@T6KDszV(Hxzoph+&AB zcUT+}f*Sooi>F|g=fwvWHJBi$^>YMIi#9v!>&;&)o$BN&-)EhkBiCQUI8;0Ji3Gn0 z^~@TCEitt(#QrjKzs8q$xB7A_7a}f73i*t~TDz<=k#4zuT-I8#127)i=HF^L~o7*I#Un6GChv9Zgv`}!t=wJf|^nl6fHnq3HG zCBr`AGm}E<$4FBGRx|u*8_zaJ0VbnW-GgU^KM1KlaHC1@+zJmmg@G zKKeXWCQQlSfz+Zkhq_Qk_xM?canD0YIokq%JIvJu$n&BNhy<8E6rDv7*#Eos;A4X5lt5<0x&ON)1V}0vH*w&h6xl=1=I>mS%*GD6>=CK z4n0?bP=rvdvQ-TB9~5f1;*OqH7DFk_eRM|i<{5@@%n7ReWz@eI@}A6%4v+J3zD;dz zZ`W{towO6_63$pMX9+D~=74yS>Vp&$70Y$%%s-F8Y=mC6hNN^V$|X5k;P+-UJtq`f z%&Cr1Kp{q{g?Wqug(5mo`Qhc*!BA;kP!-jAg%hLPdhuWUXE;VR&AxQSGa})3t=Y9W zMT;){Ed!7qkdWF;z{z_e@{^24H%EoW*HiOlVGf914OnT38;qvQ4n>@G zuBnOioZQ?JY5x}$^?NEVZRM`#%zXXNfwo-^p!ZQ^6&nP#MB1+t&|-RkjZB?`MZOzs zE1|s~f{I-ueZIciwzC!8oQ82}qLPLw;-L2&wMS}1YXeApI_{$!`6TdD{Z&Zf=rg*4 zn2K;*|G~;pJ$5aM)9FziVg*3eZCqKaZ3`z%IiGMbaR>;xv>VN_Y%44H0AHu}>vQ(^ z@?*ek;@1bG4}ln6^?>i~s8#<6{U*fm=KO%UPbIgFD&X9~&V=Bokv7d9w>4WoR89a{ zO79mV6|{GTWcoT$cW^#l?VkYwBgwoZb8AlR^PNyI4i7&FfWE;rKkZbvtXq*S1t`EI zVhLn9pSOt#n=7rPR7|jSv(X_!!RE3m#tJ!m@SQ7VXOTi2yB<$Mx(FuRWPm9>3^5l< z>{Udl?AwUB%GQ)#y6X3FbM<@#sKa%zPVu`Zr4gqIS>{lvb|F_uJhjwO9J-PO$iPzpK?I+CNX@k|V60HK&Nd2RR^P zVc0ms2c*o~47mkDb?!rkefI&_lFu{K!#w=_F(xm`@^?l%8?|o}teOa23(hLD>an9p z?$k6y$&X^GuG;F`$UPzUjpqdkgOy{#sB-33llGu{W0W~`Rt&j$tY>lbw}I~X#j!|*(J%JcHurH z#d)}mV4#x%8aO?9t!r{1nkPGWde{w)K!al9fOh!J(&me{{Zj(NX?toOcLlLc(HIIO z|C&l!etoRh=8hW9oFQhzR*YLnka-KW;)1Pa@52`=z{bxas~&PbGM>+@f}B{dSTojn zDgRms_$Msy#YjtV^l%$h5bkAo<2NE*5Xhm}=N=VUS=aFF$St@{&Vc%(U4l^Eb_yrb*ys%RL&+cTxJf=Z{@wEOP6!qB4Q)#f@ue!Z`z48-{2i58 zeTjLeMXOS;9Th&84WK3izFxooxTY@a9}Ql=&2~L)9tF)eU*UYCM_H_}y7VVYo;ay{ z_xoIqDkpi}Hred2)DFfVj+HU zuf2^rmmT5TG|;&f#m;SsFsb~PNa+WtcRBjJJ%_R_&v!<}Q0O@|thVP52-F0>=*z!a zk1aP_(`RP;-B8V80K~?PM(sD09&mfK*#FwnYT&m0UDwjHk}HPktt+wxPZ^bBnJ zyw#$}^soN0;YbUGAnodJ6wT|Rg2+oGP6F)m_fwMF<0FMn8_^aJH(@J?%r!}Itp&U0 zT-^*&eby2Yj7B>DqSy;xd4=S_MPaV`&b|_a_7F9>O$7+FDK(g%>-Z5rN6{5mr(C2$ zUarfFcsTx~v3O>qdf)yBF62xOKYf8S&M{`QXCgbvGp`1|$QL|zxGWg;=?^>Me*SCW zM_uBkIBG^1$MZwV@A<^GE`&Ff~Aah|0#MJ2d6{D&8Np7!$=9 zw${_^1^H(CK9jgt+x9QDm2cE%U{Somi4FJb3Z^v$EJ4*fzI2kWo_HGyi-3@CFf2m_dAmP-t}}(MQ;m#?%2i08Nm9Vw!#OJQhAZCQr0KK#A!jtBB>1~&L2iX^ zNJ4(`0jA!-lYSkCH7yBP5gHMl6~K8o{ld73+VB@JB|mW-4r3 zH+USHRo7sNuz?WMkl24m*z`{9BOASCI)A?Hgi&3y_(Kwx&D_`ePpaauLHihJmLGvB z%((>{qav;QVhO+?95@PYPFyjdQQQ$OH{oidVoQZ~rO-Z){3zT-FMUR$CCNn2`))Mb zodM|Y^sJNf!4zuj=c8yzm+ZU4ZQ71G7GP6LjYDRHbzYl6zU+4XSgr#^NeTzy5?iw{ zfBs&v#4Dh{k8#6bIpH{VEN0zpUwn}>+Wz6*|1KR{%HH44>3+p!fi1fHEgc0@FDxsT z#kYqA+j}RB*ctZezX%Hpr%?)22_y!lJ}&n_s~XaqJ8Lq@x*oazF9SA7a=$M%E`G*3#40!p`rM(S82D!F(&pr{bx?& znzIBBfufnOOxq(b=bWP|%rgrwh(XYz@A1t)^GRHn#}_Ro@tnD9B`^A|$0h%|?1)Rs zRoONiEqDz8J-7CIMkM!hh3^56=`5$lnM5G3`q4H*_E7wl_Urr}y&5YU(IGhDNKm+% z;_`HE<4O6(lQxSV55!8tRl>u#pg2o)J3*$&IFY$fsFI& z>|dTma*fHOfQIRHXE-n8V64S=6bdIL$Y|<^%;DW683yhW!!5uRFdgA2hR{TB(@j_? z*>m?Lg3~Mh=^ma4MnZA{LUI*~=>M_Ad5zr98LF>Y9o(asgMx%v>n63_?B?lEfE5oC zDhr#DsML>hLIYHQOS(Fl+2Ba5fQ*pB$b3S1kVinLC^9Wk#1_i-Z73N6-dH@&BW6Bk zI~tptocsQxa-N?-`^Qi6w*a}7_Z$Y)x8#kdQH}g-TUtJ6_`d7jB5AhC9GkK|12+TW zC$9ONkVMz?K1YGCxoLv*$b3SQ(YI4pla7MJKMN3c;%PAb46UL$!Whi+E%;cyv+L35 z{E`P!{O9){1$1rEeq8(JAgDD%KEED&7JXd)y=W=FKPu3+s?)Fhm?1sRgB7dVL?7P| zzFhwKJ*Ft=9Rr#iK_u0<5$9o`ZF+=1hhsZE3K-MS6BIimNckxp3`L2RaZy1Z<^lOj zbX;JO<}gc_^P3Pq0j|G)vwlnMDNGfX&RfY)?Ut6V%eAr4&7 zxqN7P#$oszY3%a6DDlR>G%e}3w0>I%aRoJ)$H!I8$c&}>2Fl<6Rj?F@%clF-)&yDt z@IkKeGdvedUT7Tt2JmD>zA*JEvoLg;ntX<(Lt_^J>h z{l+OGgg+j>^R)y!m{gA$y_B{t+0*Tk+r!1Cu;Jf8gwUp(g$23|tU4;0`pt$sf9$ev z3@X}Rnb@vRZ)0gKqk694>WSxY@i-gGiga^s7q&IiPrF=3OVGLZbsrkt)!~E*j?V2@ z;B%Irp)=?yBZ;!mBvhA-Y%S8Mq;~a9QPkqy4HQgApAN!HYL*7(0j5LZQIwzS@$Nit zdMP+p;6a#pgf)eqNMTR*O%&hxsrtTuPjT@u@N;U)ImJ3Qzk{1IJq41UZx82wpTQs1 z}g1(&Q73sudsMlasIT_1->Al=)q2k=@v zDG6yw#PManFjDi$>+7qi-GyIP&w`7i3q$n`I@}x$I@ISE*%Y6Ju0~8BBSJoB%n&-C zI-3oZrb7`AX%LaF;)mal2T6Pe1URZaG4MKI2h1gicsSQf?$GsJ^L)-P2H$@N1y|7@ z_GDlN^KRRd+_M8J(Y&mZQ@2(=)G?^0U$+=Ua-AfoB+C23tkx3XwrI0YC)16aR z{pLB|4q!Wy&O5c3J^eyA5_1qP)(jT+_@_%e^zSQOB7TPJCmp?3SsAo8!I6mMewg)u z#z68}w+_yf-%tbxZxOa>M?nj8x|;Him;r17zENwovT}eV;Qf>wI(oPGF<4;UTd4mF z@OkV?Ckn|lkH2`gK{KBa7=2?I>MOt+z1eo_Y4_6SB;Kw_W^SV#5%T9=xhAM}w*ekg zMs!EGM?5?A%(fkwZZ#ow2gjUd338-J54eSZsVwCw38gS`9TsJV>Ofgq=2V}th=nJI z^z)$zZiD_;p5yp&R@Qt5wDjl2@A}|2%e@|?I6UD}5s_X>vS{C!*+%7})jS1WW+Nfi zOqvu1ATO_O@wUEC&Plj!roS(kCcA>_f^7$z-Cr^AA~^7|&HI|!@-VTR--@}K1Yr4d z@9wS5Ks1i4fYyieb3{55r8DYYSad)ZmZt^G?)rer?Q6?MP+a;f3e%mGYm$sDuUA|> zvkz?h#aEZ79|hSb?-J(@TjNQM0=oQ>0nw2%!OO>+zmC^&*7o5mK51Jg#_>n8E4QsL zPsmmzi5z%MMjXFo1|6&s$FB6BMrxlp0E=rHm})VC>&#iRSc4GBoj(HONt)>Kxf>o9 zAd!y?B^`SOrDW0eaOTB*XY}J0J`Dd(lG7(~X_G~}F_Y)6Z?I6A)?b0M%xlB=WGjOA z06+rn#r%1{=qQ6O8G?3_uUfip9)!Hja`O}XRyZGfIuE5E2S3F66)fbL+Na1SrG-F~ z>LI5^# z7tqL1C*?iHO6EH;)h&fZZ^ECXzyiHjxzHJ+SE?vn=ZMZ%JEw@Q`+v1Nsc<&ODY9ay z7u|55ILaSKNt~C)E4?hdI?g}VeK%9KgFRMrK@n|G>oKht$`828t723m3Jy0H5|^=y zmY{@ZtaZV!)ZbvdEl==^ZwT-z8knlQ)%s|IU%p$!36ecxWc+X~S-OV82p>474qjG> zPh{fun_^_Lt4=2d(}q^XH07jVBUS;EL`zk7@)IB(!MXI=p?5c^LnHA|dUT-HijEP} zKAW%}x0*Dws_MY!(@eVX==A02d#W{mzu@4ozpV_BKX}XXIbGq}!@4sX0FlUm!Dfq< z{q-BCCF38RDcLP@YeA3Fe1Z^{gzF!RR*iWB6iTo1N&$*RB)W=0r0X~s0Q#ByKFC0* zzB;y21xo#3%B%=K@>kNdX#EidJDF|<$?j-z1V~5)cxL{6%jgfGCx#M3&d1Y0MzBUipMmsw6>&I&$eUHctt0`0}Fn>!Xn}PE) zN0<2U9Pjw$OjTyT>~(q@tsi$0#AAUM0QU9vY4iYQ;h~8Bx$j9<(Y!sF++S2XRv>$c zY=ey@LD6oH8g5jc`^GbY7HL-^pX=3Ul7iVi>(g%%Ft9`sP$m@&P#(3o{&P?wgum_} zZN;$j$#>-~X5^D2ari|F@1Ogw^-;s-D(7&N-WTT6y=(~}x=vnsdinDpqg@CfW3v5D zAQriWNV~nk+APCGc|}y@FV*AOpQtCJJ;4({=g@C!%}OWI4W?h)vaLbtpBe#WMS*aK zAkN&z1N&o~5Q0d$o`uwpvSio_v%wO6NA#O-LX(5Vk?5mf(myH{BymhKK0nDT7a4qp z#im$dm*}g!HP=4>rjj`iZby0PYbwURU+j4iBGR<(dpuqfRkaCrl(5)dEZO~70Nj%h1MQktHbjRaPHy{c&FJ@YeVy7;HXIw;sGr zQIg8`cW8icce942AFlyBQvSU+=6k7EHJxuR9A6)80ow2Z;~RZLK=b_}|1M{sA-Wp& zGpjngfnb(d<7=@J3W;$A!7B06#qMR1vNz z7X!`FN*x_s>VD);dyX5jN|cKaX(Ry`ZT4-sB3T|gV(2oOTKFZ?7H(@RA=1 z4U1O3G#!6mvvzsn{tb`MTHby!&rfg!`uR1W-Lt}79AqQ+R+BYKv}f)k=a=6F7i7-A zJr}*W;v~?~&1<|<)OJ@~c#aFFMPV;N{~|@$PgP*9^BGy`Z6qBnfIKvD3hDE3<+)AU#t(=PR zgqd$=al{l`|Aij^ipvz((F*qzo4hbnij}FT71n6Rzl&qN{LS{0B_LS%s!X1ewG969 z)}JU!u@oqq`h48I_cE|VL^RNY@5^(~g%X3(?4+D<6v`iOHAEwea3xS0%AL znz4=W4>2j}6yZORNjb$Dglg+fNr5>znmjAi_-eSNd4SS&M$>gd#H%R-%9$t59ncUw zr6)dY6HNgVn3PCvVqp-k)2BZZKOnxzI9n9xK9}-8@}mHR$sYpnjG3iVuZTL6GM20e z$Sy=F{vBZgXHfy$0@a>8HcWDW_@^`!1@q1?qELs!->ENK2sZ4*btvh=Ry)30Hz-1tup>C|0~tYm3a#HHrENr-(S4EGSxxC&F0+sNzK!rwxZv-FIoM*NE$ z$G!yoys7Jfij$V*h_o3gp(kM1UK?iO&9hsh`|31_0<)Os( zrHLOVImg@}o=nNBct+KM-hEi587*3loUFGVqNGR zeS4p6nae&gj(%)vVA1zTixvybtrS!`(kJgt@9+r@i#z&fr$v&DRWJr9b?VC7jDOaM zm|q;w$G$zb&-X0E$}$hf!`rs!9czZ7a?Zudw7+aU*N5Dr>LL>T=($MfZ(0wbo2!R3 z$L(8MAru@Nyp#@IhCCt?1@l%AwmOkmX>G0SOU=-!#~sSfRL#LE957_{&R}hKuaI{Du7wTELiQ50Hv`+ zYfUY(Q9Z@Y?4Ktee(({u=8Ws=!wkLS#)iUJqpn05zWi6+fq_%!F5Zh6MStk?;gH%q zvghuf1kS6Y2t2$3E3&gpKcV3vfkd=#g+#cj_HK}qgij^}9S2xJi2a1AJUk6B)x4{F zln8-co6EK5D8FyWldVt{Fb(7BC1GxA9U^_p%TY?P&B~qQ*^9l`4-W@ZD^$~$cORZh zlX?m;%a{)PMiv%G#}qn8jz`pZ+R|l^h>6leah~4ocmEh|-}9R4kCi?O4lsD)mC3U(EeL8e3VQrDzH(|ZKG*ROL%SPPA-On8NSDGA)bk-0OQ;8#R0K>yNC*3R{trMX()V$8C4j_<-w*i}YuU@=e!cwbka^Z+VUS z6L0gtWVT$51V4z=)DPl5dzD*?T==Hk1fxBE`T18;%{=H@kz2;{19FtQjD#XH#|H1p z79|KI~WQT-cDIkZCP=WB|4Ezu2ixga}ZOMN766A$rnfQ3j$a?=?bzbBUu>VWBZ zl5`qv;~{W31fE7!B}%ho*^rGf_PrMW!{np0fRc$E@D`AC#X?zIsr-`ES3Sx>-J_#x zM+$_>>*t~{d1s!D#gC+7m-cqnq${Z!egP!cUi21YGGw(sO6tC|uvp*0m4lS4|CmT~ z(_V?oq}$5;B3rvwZqG-+v+J!*Ze)2Jjm;Q(^1WmfHTg8n<(95fx&(r6HZUapJu6VI zV5W6Y{q46|7ujeqKnsnlm5keTG_bbmRkq?n@W0AGP106hdZ9JR+2t zGM@fC7A#`V{f{JhKZb4j-53T1pc9oQ4QF$c*82cUu+)&9AO{4Or}RmPUGx5$<44+7 zZwD}O0Nw{Ip;F49NGy}>+XZ}G$WtJRMRM+PZOQWxG0|M}oRV70@ZD`s=tN+6QhDa< z8%|-g`gkyFkE2KmB*8RvEm#S;Lgl}sW!Ad*`K>!WKbC%MWGgu*tzDXG^@%Hk`$N?f zqSNMYWilQ4m}HCOn*lQB*Lvh`?>PF0N{ z7#+KN^}yrD?W{+ALF*{WMqB^|x&`{N_+9aI-37WHql|aKdP97UacCCgAv5uEzIQ`P zN*tdW=94e7wQ}9b@6?!r*w^u16!EU9XB#s3A8}e9nD6IcdlqK1q`jOrjCR;yDM+veOFwuJNm;Nd5nuY7D zLLSdf-XCCsLLzqj@4yjQ32dU|X)gGn{8c*YXoiS4jqDMs`@_FG ztZ>XhtnH=q9RPC>0QA(U)b?Wv^&LsM>K2OSo38d% zK=t2a02kzwGm@h60o0Da&S+ax_pjo{jJ1>Q*vWhKSwsX3b(~uOB@2#2c6ndl8Sq+X z-kbSutU2DKd28f+Q!!RXNi%G>J5Emv`~yF&=xKv~@e@j)nAU-g2llXWn+M8czlisV z4tGzy?TgC$ZLt=MJI({-(P8jdaJQ%$rQYw5{0KmQHx8m14{qRA|JF@R#&7JWy*ij zHWw_zV}wJXzRIp9bcIhb$T~m#BqE9sGfHNsa&9@8c?Rff4UeCFeFbT^^dkB3UNERK zn|EQD7c!UJ#$)-!r|vVoE`6ol%blATy<$v3W-Rf6%<|66)lGGgs7{b@hF?(#{Wv1z zyDd6U%oe)Fp82%EiD-WM0v$j@0ev3edRWvA@A`57IlS#km#cC9%Cg50VAd=-yoqlP zGN+T4xVdD#xkeY-H&t@2q-l6!f63+VT; zZ!2n2$qv?g>3Qnz-i9+rV?-Tyt3L7#V8DIfI;k4w=%qB$X ztbm!^s`NtAy0!3LutvrbcX7#B=>T$iy^c>=aOig-&VGfEmN;$`QIL=B@fx9OEeam3 zn9iiijeAJxbz6PF4HVJ#iam9i-#pwnoih4WtSQ#JiGTj(u+pTpT_%CzDNgB$<`LN% zqg#$KB^F%?YcA~H8c4S8@cjg+IR7`cK~11_liW=FWyAyE?Hozpy*je)7#$G|`p6S^ z*dnR*&*gO4WwXmyq~i|Ile{PbEv^g@_(mreDfsU8Ul>KWPVy<9JGeS3mE2Y8 zDvpK@G?9;D9>UuCMnUI*&u&y7q5r}kN%h;f3r*g*wok2jVM9#Qvl!CW4WMZSsiCre z=)!y3Sz-T6FMqvtU_d}pzV%pn2rrkgORGe`ugqVb;+?%bV zW3)jKs*dK&5t5+Q5kK#~`WgDRh`NmH#=8n3$w=>0Zc=-_-FWFYkZwEjC}LxRS;BF$ ze8U3y;vdOay?D!*exks;z82*ZY+7{(&JFZsDz08vJ(AJ$US`dltukA1Z%^{uIgl!b zPHk?LqMTTc=IEZd_!5$e1BKJbjP+O1iO@kJGppd;s{|3tMK#1(_A^6uc6JJzzV)j9 zQ_J7qXDxzfsgYnOi#>$p1X#EU%+v1Q#GvaI*s*Qad36pGY&;jkX1kl*R^9QUP_jb; zSZKD<(StJYtDv3&+?SN#jV49GCc-L`b0*BeVoi-SG0;AG7#TD2EQa+ishl_4^heuQ z+4PDg90mk~PoOGE<1gi@5dXjy*Yz<>;CQkV6AFI3K~Bpii0&V@MGZjpk7AXA(Sm9X z)t&Sx;^)KM9>U#Wt*vImg1&IKzaKh;w(QO1(7V69$`<|L`_SI%v76Ct88EaSVuJXf zm(dZjZ75`8{0_89YUpKrxa|LKTVsmtD1DLMGSm^R+xJrXVW(hPBVzW3tD|ZRI+5go zrj&9X06uR?Iu4;}Q!PVGBhejzs9Z3$PGDqxSybd+f_kZq$8WP)RZiF;M8QXWt>;F% zE4Ue-;gXAx&j)A_Ml}~PJ~_aCgwHDQ{I_6mbCgJySq%4ph@G)arxT@KuCnqyZRQtr zBg>HIk9B%mPSV9W`UM7dNEkAqGS++mn6@8^Hrb{cEqeM)T5MIjd{>>KLjyA!8u&rp zj$VREe!0RlTZtZ#O|JLt&{WraM=*^1$6j0w%e6=&qd5%jQ#J2=+yL)Pzzueno!~?O zK8MB%w-GDg2@VPga4CR@YzFVfrv%^M=+ zSRzFdn4cc*6ACFpE9@j6*E}nnJXI~(%xE*QzrfDz7!7m=6I+zi)ey(54TI=LHL*q$ zotPMtzgD16+)+zFm!C!*zHnvCbZ#V%*mPKB0MoQ=q>>`bTLE_C-?-wTJzA=Og$RNV zDQ;LK-y(+17(=?hN=7T{6FQsfVlb!{z4CMZBVO|(X$=AMXRZ}v1^z9!guxy6q%pGE z4%mpM8Oc$>XQdTAnY~@c=6DaZClm!+;r%>cHM9xg^jtacoEOPi<59&BrpXy7~QcrBM@M`UbzU4rSd9rfPw{PUGY zujAvjG=?>_I>s3mTMX~ZW;xvrrZq{viqBF#I{e+oB8HdHEjuFl<5aV4Fp=L_XpZZ3 z!j0df^tMrzY!L$4(QOALO0^9l`swKsw1F;mu!jd;wwLe)+Wk7KP#Q;kQo%}>9#AaF z3x>8$)wh1K5NTUI-?QXkJB=ZV{O4;|tR(k6IHNVrfl8W4WRKH=RQ zZ1V#R%Vp8(E2?-+g5g$J)SF@hBmIDvG}W>>{_j*r8#i}z`SS1+=|=+ezu;QR=uZ#D4h8&!XnvMpfE)#+Yl8*mq# z&e)$7BiN3OThtJ>nZ!@>2kavj@~>_ay2?POL29gFf-fU7VS*h&(4Dx6VjiPrV;^|N|2hd{F96d7ueW{h{H#n9@+I>=B~R7ttA?PPt|xrT+i?{JJ+)Eo>AJ} zcz8uu&ic?xY9XKX-CgCwz4A6fpJw|*QsLO&6BAY`XDWWo8uruYKkS8LVpr>aJ=I5U zC6r*TrYn}tiJ3g9z?OFYH`V-^Bb7{Oy*bK>cLUzuhtc!}MkK zsTq-njO|GW)JdFK6BIyy$Vs+X{_!FUc#1*0C zr{83I{6zo$ZuaG?kieQiIdoW7xeE3C;4?VE+fMst%wY*T#0$a-GYN>v$x|PG! zF&h|5=*uZBe&2uiW=_Q#{RWIj8#M3eB#WKyiJ9pE?>Q)zMPRx=zT%B~1>;F$E>H8P z;*4m@e?;hSLI`58y)K;0f`h&KVgfo6adpYy3I`1>>2!lU((W7SF?*PGPjNm|DO8_5 zL(|6NkJRHD4Bu9MJoYO(fwBC;{EJls6P| zJJk6E{_duid9D;Q|A`d|wJQ0}{;J1M%F?aG`LgJ$7?^X zDx_Xma|ITEZwbIj|5TPf+*{a{zb@f8wuhDEMa|4ZuVxASL-n18?wXGfPcYBD7L-*3 zV>)?We3mm?eX37U4}}nYeD%!06tDh+)F4FlFKd_Yb~cU&W}LFBW#QRI*z~^fzlsU| z6qxPUZ|EM1ipwhq+hm$Q1&aY9s4iqJ_Ad-04YPzfVNx)t$#?UDrbR28@54Y!L`>6s zoOD?Gi~1SL;4r#GFG8P#N1kT7s^{6}zkk<*t?p?(@=eonP1E+JfS*Nj|3JS-5RvD? z{3ZyO5x5gOZ;c)yukKDkD!nS<0*LB~pE(Jm?uEl<*Ws6=9_(#_oIagal&hetGiN{v zpCEOeH%q{1`3pWWz`?Lz@lb1(qEs7%dk;~j)7R}N<(9i%UqxU6GsoqyqS=V1$R&>>u?D2-yD}40x2slfE%>7+0jn zh(W|4!h{!h2wur5(9uJ$5Q&A7G=SZg-G_ZW{dvP1G1$m8AqKrYPNbEqmcRxJax++) z=V9~NTHDXl+(j$s?MM51$o5PGnm{_KIwJN&KT+%{@=h^Bl+4fq-4>lW>@M?qltw}t zk{yMm1!(I!zz=7}WT>X*ZDo+lwDsY)1uoX~&b?ok>FLtaw=1GU8TkrO)9q28@*l0t7dnUfmTAk& zmK~_BWvIhEK7Ey1GR^K*QKi~6z*Qk&X!#I2G=mhgkuB=Tc!JDt&MS*vrT3Li+sR7~ z&I~qkq*0fd_^R^r^b!*UvU&`}BISO?I&;TFLzRz}Zd3JzVMq`f&@K}$w0_CIJfE}< zee+xH;^_23ynTNsZozVKV_{)qQzqGat?anl)CtcigW&@B-@4B0dHgtGs%3u7Ma>Ws z_WQRy&6mCTu=1W4W{o^?l|V_^-CZ=qHmJE!RJS2!f$>vcOM7{_@?^G1{c+{w6itq> z_pBO+A!koxXK zCu4rKWIG~yf+DXDt6W~43jww%hsGG%l-gWb0`?PSfwWIHqy38YT$W8&e-6sZ(#w&( zu=q+5-bv&9~&124e~i|?K)2eiP$bf7h|)RXZA%%^H26m0-Lt@PTE>Xv=a(%tCOyWN(Y zwxH+e>)qj&x#>adegwxDYoD)5xP34<82`So#w;V4`m#Jbs8SjbBJ+GK1KO2=T*I0S zut09HS7g;MRKMx1j}YDh)VCHNrCY%x+lj-Ab#2y@gN}OT_m2{bN!;0C*p_W+x{;RIQr zhjhhPH`aC&=?d=gp?=|7Qux#dCtR+qwsbY#)7ky~5vbv-90TA6A}JBg>qI)E`ThF+ z9`~h5grSm}08VuuS|}AEM^!@I?dZLbpP2G~CB7_fntIprr8ovpvfX-Sw&x7qZd2Bk zRo1m)HF!Q1r4LC^Md4+7%NzW1ch?;ZzFG-$sqeaD!6M6hup)@?_VF(K$}1brBTMy{ zLJ@xuZQ?E|Jhhh4f5R{Mu0}0THFYC>ZBSsSzaAyOCM3KDI>;0oLz{; zs*zB51<+i39D)qxc7`HV{d}f7ZkB)0^-x{@gp@bKivK;QF>l~RB2C8&XCV*j)4l?Y zP=?n0X8cN%Yy8Q{$(TmAaP60a%EJ)I6s)*MJMT+qtywO$QgD9?A*vX259m+wllzEt=e zje=gTwL(7M3yG}ZJQqr-OiDf6#TY{ht`1K|Zx(MSPUxAfjqp($!xYri)I4tXMm6?u zfJoI=ZZD5&Jxv!oFz=l~3}>QwP#4&NCA1%V8wvM-kA0kqS2Qe!gw;TSB`Pzsp|9*A zeP+6^{4F%NbWtt1yWOhA)Gg!Xkzv-3U+K@RXHe2}P{R$zBf)vw@ga4z4^!5P_mltL z&}4Q#^pMK6b>E~DM^fA45ge#9NXY#(WE%9v7$LnLIeZ%!M%eof0>E6)@ zqVv_Btggdh()UjSjj#V{Jr9vo3CU(>(ToLwkG@6mWaM)v_6M zyJ!`fk}0X@Do%X!=X+K9C94J?MY7sA#@aLOSO8-5(>Clr z|9i;mx9qxHG0#64E-tlkx?CTQ0?&zt6X}%m!)i$V9Mi)wu&^zyRg}7w3HUTuXUW~) z{CvNZ9!B{2I8gI|&+`0)46{?3#uxK9SRivRH8NL|@CA7%_(U$_lk40n8w=`7BIp;K zYnQa{epsdqeW`~X1b1?U1?_!m-g~&*lriq#i6MEMKmV4D(?n()$*2g0N{yn({Fz(2yhi(!3fxn!D%C{Nish6|v{MV#95uRFQN6X5Z zLx&yQ$Xu;22*l|f-}(Qgy2YfNkKa8!y2*UL+QNeC`ZofbFAKgKMfORvKbYe-6)fcz z2qEUNzj+M)m7iEH3x_hI+hO$cqo8acXqdml2WB$H1FYf^oc}=YLC-RWRzAz^0*4b> z!NUHi<5^jAo~&JSc}$iqfKl?=kt1sA&xgbgsXCgwC^d{FxS}TSRDL{SKl$bZ$8W@k zzrPrk;PrApdyfHnP1e@Fy~*;w>P&Y^j;c@I|0HkKtogzh43(;%fAW1CaIg{0lT+9F zE0r}e&6eX;aEbF?=12DhGz`kq-VwO*dUl)ZCV4;q)9v*tMh22j+7X%U=u||$S(xme zTz0Qu^31C0R!4c>sho`%dxILT3PdFi(_&Dk65;+qDpCKjx}BTg2hAqanP4x^B}JlY$gni!yFk~xe*Wj_p$ zcg5sC+`%sW^oz5I+%^S7Q6vVzjliB^Xe!}nok&)2$vc|jDTe7B5zQLAu6@WOqksgA zi#BzoWBk3JrMaXfrxu;w%om$ppL=~Sl&8se+l0=PYe{$bAM!xY$=`2^$&QwdYSNONDn3^uWB_^f=#eCb*n9{bG9BEsSb#{YzfTJ=d0QeIk>Sy4 z>JGo_R6cudD>3(%yGo*!;pS%bJYGjR=fKmFNUuNI-I-Ou>=hN0_|lXq7k-wkw_M32 zssu%A>W%hTud|Uj(Vz7Etjz`y$9Y98QEj>V!=t4yk%?nCPr_rqdR4YR+h7*_BGkU{ zEkGSfJ7KY7dFNS|B-tSA(c^K~0&e>8Nei|GG_ryL@en+a6kbb~`Mds;b|q(*m)F%0 zgZAzhtt22DfvZVw>+QeCZy=G|Dbr-BIjV?BJ&PIK}UhWuxOGj68^70v-c88r3KPzA->G0$g>V>UFi)fj(ywtq zQ01RZu(uC)SWSa+CLTrg-GJ%%N4hd`DO)E=s3^SpAL*m}#>VkcifvG|uP-%8o?jKZ zB0Mt2MrJ0MIC{=TRK&Zz;ac3-)V`DHX)?poFHAI&uIa=yp^o`{uj^c9~7ulIK zg}x@s2PL%vKl?>l;_7H;h(7Q1!`lgm8#bz+Lh#(~>tmxNH+{Qndh{-3K3 cl@6|9NCmcRiL`MmdI0$GPEk{#PR=^~e_&$53;+NC literal 0 HcmV?d00001 diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 5e7e10a7..da280592 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -749,6 +749,7 @@ impl AppView { } pub fn tick(&mut self, cx: &mut Context) { + let started = std::time::Instant::now(); self.data.expire_toasts(); let ids: Vec<(String, String)> = self .data @@ -788,6 +789,12 @@ impl AppView { self.drain_debug_logs(); self.autosave_notes(cx); self.sync_file_draft(cx); + if self.data.prefs.performance_profile { + let tick_ms = started.elapsed().as_millis(); + if tick_ms >= 8 { + tracing::info!(target: "perf", tick_ms, "ui tick"); + } + } } fn drain_debug_logs(&mut self) { diff --git a/src-gpui/src/detector/mod.rs b/src-gpui/src/detector/mod.rs index e39d59cf..30162e22 100644 --- a/src-gpui/src/detector/mod.rs +++ b/src-gpui/src/detector/mod.rs @@ -246,6 +246,7 @@ fn kind_name(kind: AgentKind) -> &'static str { AgentKind::Pi => "pi", AgentKind::Qoder => "qodercli", AgentKind::Agy => "agy", + AgentKind::OpenClaw => "openclaw", AgentKind::Other | AgentKind::Unknown => "", } } diff --git a/src-gpui/src/prefs.rs b/src-gpui/src/prefs.rs index 77fd0cf8..d3004de3 100644 --- a/src-gpui/src/prefs.rs +++ b/src-gpui/src/prefs.rs @@ -116,12 +116,7 @@ impl Default for Prefs { custom_shell: String::new(), notifications: true, notification_sound: String::new(), - topbar_controls: vec![ - "github-desktop".into(), - "editor".into(), - "terminal".into(), - "pr-status".into(), - ], + topbar_controls: default_topbar_controls(), editor_app: "code".into(), terminal_app: String::new(), accept_beta: false, @@ -159,6 +154,25 @@ impl Prefs { } } +pub fn default_topbar_controls() -> Vec { + vec![ + "github-desktop".into(), + "editor".into(), + "terminal".into(), + "pr-status".into(), + ] +} + +pub fn move_topbar_control(controls: &mut Vec, id: &str, to: usize) { + if let Some(from) = controls.iter().position(|item| item == id) { + let item = controls.remove(from); + let to = to.min(controls.len()); + controls.insert(to, item); + return; + } + controls.insert(to.min(controls.len()), id.to_string()); +} + pub fn upsert_template( templates: &mut Vec, id: Option<&str>, @@ -217,6 +231,27 @@ mod tests { assert_eq!(templates[0].commands, vec!["pnpm dev".to_string()]); } + #[test] + fn topbar_defaults_and_drag_move() { + assert_eq!( + default_topbar_controls(), + vec![ + "github-desktop".to_string(), + "editor".to_string(), + "terminal".to_string(), + "pr-status".to_string() + ] + ); + let mut controls = default_topbar_controls(); + move_topbar_control(&mut controls, "pr-status", 0); + assert_eq!(controls[0], "pr-status"); + move_topbar_control(&mut controls, "editor", 3); + assert_eq!(controls.last().map(String::as_str), Some("editor")); + controls.retain(|id| id != "terminal"); + move_topbar_control(&mut controls, "terminal", 1); + assert_eq!(controls[1], "terminal"); + } + #[test] fn missing_show_all_fonts_defaults_false() { let raw = r#"{"language":"en","theme":"system","radius":"medium","worktree_dir":"","debug_mode":false,"performance_profile":false,"show_avatars":true,"sidebar_width":250.0,"sidebar_collapsed":false,"collapsed_groups":[],"profile_sidebar_width":208.0,"profile_sidebar_open":true,"terminal_theme_dark":"GitHub Dark","terminal_theme_light":"GitHub Light","sync_terminal_theme":false,"font_family":"Menlo","font_size":13.0,"default_shell":"","custom_shell":"","notifications":true,"notification_sound":"","topbar_controls":[],"editor_app":"","terminal_app":"","accept_beta":false,"templates":[],"collapsed_projects":[]}"#; diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 353c1cbf..32c2fee0 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -83,6 +83,7 @@ pub enum AgentKind { Pi, Qoder, Agy, + OpenClaw, Other, } @@ -107,9 +108,30 @@ impl AgentKind { Self::Pi => "Pi", Self::Qoder => "Qoder", Self::Agy => "Agy", + Self::OpenClaw => "OpenClaw", Self::Other | Self::Unknown => "Agent", } } + + /// Inventory §9.1: tab icons come from the title keywords, then the detector. + pub fn from_tab_title(title: &str) -> Option { + let lower = title.to_ascii_lowercase(); + const KEYS: &[(&str, AgentKind)] = &[ + ("openclaw", AgentKind::OpenClaw), + ("opencode", AgentKind::OpenCode), + ("claude", AgentKind::Claude), + ("codex", AgentKind::Codex), + ("gemini", AgentKind::Gemini), + ("kimi", AgentKind::Kimi), + ("cline", AgentKind::Cline), + ("qoder", AgentKind::Qoder), + ]; + KEYS.iter().find(|(key, _)| lower.contains(key)).map(|(_, kind)| *kind) + } + + pub fn tab_icon_kind(title: &str, detected: Self) -> Self { + Self::from_tab_title(title).unwrap_or(detected) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -656,4 +678,19 @@ mod tests { assert!(update.alive()); assert!(plain.alive()); } + + #[test] + fn terminal_tab_icon_follows_title_keywords() { + assert_eq!(AgentKind::from_tab_title("claude --resume"), Some(AgentKind::Claude)); + assert_eq!(AgentKind::from_tab_title("Codex"), Some(AgentKind::Codex)); + assert_eq!(AgentKind::from_tab_title("gemini-cli"), Some(AgentKind::Gemini)); + assert_eq!(AgentKind::from_tab_title("kimi"), Some(AgentKind::Kimi)); + assert_eq!(AgentKind::from_tab_title("cline agent"), Some(AgentKind::Cline)); + assert_eq!(AgentKind::from_tab_title("openclaw"), Some(AgentKind::OpenClaw)); + assert_eq!(AgentKind::from_tab_title("opencode"), Some(AgentKind::OpenCode)); + assert_eq!(AgentKind::from_tab_title("qoder"), Some(AgentKind::Qoder)); + assert_eq!(AgentKind::from_tab_title("zsh"), None); + assert_eq!(AgentKind::tab_icon_kind("zsh", AgentKind::Cursor), AgentKind::Cursor); + assert_eq!(AgentKind::tab_icon_kind("claude", AgentKind::Codex), AgentKind::Claude); + } } diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs index 73637a08..638869d2 100644 --- a/src-gpui/src/ui/mod.rs +++ b/src-gpui/src/ui/mod.rs @@ -13,6 +13,11 @@ pub struct TreeDrag { pub path: String, } +#[derive(Clone)] +pub struct TopbarDrag { + pub id: String, +} + impl gpui::Render for DragGhost { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div().px_2().py_1().rounded_md().opacity(0.45).child(self.label.clone()) diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 1466533c..3f9b7f94 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -1,6 +1,8 @@ +#![cfg_attr(rustfmt, rustfmt_skip)] + use gpui::{ - div, point, prelude::*, px, size, App, Bounds, Context, SharedString, Window, WindowBounds, - WindowOptions, + div, img, point, prelude::*, px, size, App, Bounds, Context, Image, ImageFormat, SharedString, + Window, WindowBounds, WindowOptions, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{Disableable, Selectable}; @@ -914,6 +916,33 @@ impl SettingsView { .rounded_md() .border_1() .border_color(cx.theme().border) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("⠿"), + ) + .on_drag( + crate::ui::TopbarDrag { id: id.clone() }, + |info, _, _, cx| { + cx.new(|_| crate::ui::DragGhost { + label: info.id.clone(), + }) + }, + ) + .on_drop({ + let view = view.clone(); + let target = id.clone(); + move |drag: &crate::ui::TopbarDrag, _, cx| { + view.update(cx, |this, cx| { + if let Some(to) = this.prefs.topbar_controls.iter().position(|x| x == &target) { + crate::prefs::move_topbar_control(&mut this.prefs.topbar_controls, &drag.id, to); + this.persist(cx); + cx.notify(); + } + }); + } + }) .child(div().flex_1().child(match id.as_str() { "github-desktop" => self.t("topbarGithubDesktop"), "editor" => self.t("topbarEditor"), @@ -968,7 +997,25 @@ impl SettingsView { ) }) .child(div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDragHint"))) - .child(div().font_semibold().child(self.t("topbarAvailable"))) + .child( + v_flex() + .id("tb-available-drop") + .gap_2() + .w_full() + .min_h(px(40.)) + .rounded_md() + .on_drop({ + let view = view.clone(); + move |drag: &crate::ui::TopbarDrag, _, cx| { + view.update(cx, |this, cx| { + this.prefs.topbar_controls.retain(|x| x != &drag.id); + this.persist(cx); + cx.notify(); + }); + } + }) + .child(div().font_semibold().child(self.t("topbarAvailable"))), + ) .when(all.iter().all(|id| self.prefs.topbar_controls.iter().any(|x| x == *id)), |el| { el.child( div() @@ -1074,12 +1121,7 @@ impl SettingsView { let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { - this.prefs.topbar_controls = vec![ - "github-desktop".into(), - "editor".into(), - "terminal".into(), - "pr-status".into(), - ]; + this.prefs.topbar_controls = crate::prefs::default_topbar_controls(); this.persist(cx); cx.notify(); }); @@ -1093,8 +1135,32 @@ impl SettingsView { v_flex() .gap_3() .max_w(px(480.)) - .child(div().text_xl().font_semibold().child("2Code")) - .child(div().text_sm().child(self.t("aboutAppDescription"))) + .child( + h_flex() + .gap_5() + .items_center() + .child( + img(std::sync::Arc::new(Image::from_bytes( + ImageFormat::Png, + include_bytes!("../../assets/app-icon.png").to_vec(), + ))) + .id("about-app-icon") + .size(px(80.)) + .rounded_lg(), + ) + .child( + v_flex() + .gap_1() + .min_w_0() + .child(div().text_xl().font_semibold().child("2Code")) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(self.t("aboutAppDescription")), + ), + ), + ) .child( Button::new("ver") .label(crate::i18n::tf( @@ -1124,8 +1190,27 @@ impl SettingsView { }), ) .child(div().font_semibold().child(self.t("contributors"))) - .child(div().text_sm().child("AkaraChen")) - .child(div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("primaryContributorDescription"))) + .child( + h_flex() + .gap_2() + .items_center() + .child( + img("https://github.com/AkaraChen.png?size=96") + .id("about-maintainer-avatar") + .size(px(32.)) + .rounded_full(), + ) + .child( + v_flex() + .child(div().text_sm().child("AkaraChen")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("primaryContributorDescription")), + ), + ), + ) .child( h_flex() .gap_2() diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index d6577a9c..9ae62527 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -1,4 +1,6 @@ -use gpui::{div, img, prelude::*, px, Context, KeyDownEvent, MouseButton, Window}; +use std::time::Duration; + +use gpui::{div, img, prelude::*, px, Animation, AnimationExt, Context, KeyDownEvent, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; use gpui_component::{Disableable, Selectable}; @@ -913,12 +915,20 @@ pub fn agent_dot(status: AgentStatus) -> impl IntoElement { AgentStatus::Completed => (gpui::rgb(0x22c55e), false), AgentStatus::Idle => (gpui::rgb(0x000000), false), }; - div() - .size(px(8.)) - .rounded_full() - .bg(color) - .when(pulse, |el| el.opacity(0.85)) + let dot = div().size(px(8.)).rounded_full().bg(color); + if pulse { + dot.with_animation( + "agent-dot-running", + Animation::new(Duration::from_millis(1400)).repeat(), + |this, delta| { + let t = (delta * std::f32::consts::PI).sin().abs(); + this.opacity(1.0 - 0.28 * t) + }, + ) .into_any_element() + } else { + dot.into_any_element() + } } #[allow(dead_code)] diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index ed816682..749d228f 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -497,12 +497,12 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { ); Tab::new() .label(term.title.clone()) - .icon(IconName::SquareTerminal) + .prefix(terminal_tab_icon(&term.title, term.agent_kind)) + .max_w(px(224.)) .selected(selected) .suffix( h_flex() .gap_1() - .child(agent_kind_mark(term.agent_kind)) .when(show_live, |el| el.child(sidebar::agent_dot(term.agent))) .when(show_completed, |el| { let view = view.clone(); @@ -580,6 +580,7 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { Tab::new() .label(file.title.clone()) .icon(crate::ui::file_icons::file_icon(&file.path, false, false)) + .max_w(px(224.)) .selected(selected) .suffix( h_flex() @@ -689,6 +690,13 @@ fn new_terminal_control(app: &AppView, view: gpui::Entity, force_split: } } +fn terminal_tab_icon(title: &str, detected: AgentKind) -> impl IntoElement { + match AgentKind::tab_icon_kind(title, detected) { + AgentKind::Unknown => Icon::new(IconName::SquareTerminal).w(px(14.)).into_any_element(), + kind => agent_kind_mark(kind).into_any_element(), + } +} + fn agent_kind_mark(kind: AgentKind) -> impl IntoElement { let (label, color) = match kind { AgentKind::Claude => ("C", gpui::rgb(0xd97757)), @@ -709,8 +717,9 @@ fn agent_kind_mark(kind: AgentKind) -> impl IntoElement { AgentKind::Pi => ("π", gpui::rgb(0x64748b)), AgentKind::Qoder => ("Q", gpui::rgb(0x6366f1)), AgentKind::Agy => ("Y", gpui::rgb(0xec4899)), + AgentKind::OpenClaw => ("C", gpui::rgb(0x334155)), AgentKind::Other => ("•", gpui::rgb(0x9ca3af)), - AgentKind::Unknown => return div().into_any_element(), + AgentKind::Unknown => return Icon::new(IconName::SquareTerminal).w(px(14.)).into_any_element(), }; div() .size(px(14.)) diff --git a/src-gpui/src/updater.rs b/src-gpui/src/updater.rs index 12cf591e..b959691d 100644 --- a/src-gpui/src/updater.rs +++ b/src-gpui/src/updater.rs @@ -237,12 +237,14 @@ fn platform_latest_key() -> &'static str { } fn platform_asset_needle() -> &'static [&'static str] { - if cfg!(target_os = "macos") { - &["dmg", "darwin", "macos", "app.tar.gz"] + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + &["macos-arm64", "darwin-aarch64", "dmg", "darwin", "macos"] + } else if cfg!(target_os = "macos") { + &["macos-x64", "darwin-x86", "dmg", "darwin", "macos"] } else if cfg!(target_os = "windows") { - &["msi", "exe", "windows"] + &["windows-x64", "windows", "msi", "exe"] } else { - &["appimage", "deb", "rpm", "linux"] + &["linux-x64", "linux", "appimage", "deb", "rpm"] } } @@ -290,4 +292,16 @@ mod tests { ); assert_eq!(format_release_date("not-a-date"), None); } + + #[test] + fn gpui_release_asset_names_match_platform() { + let names = platform_asset_needle(); + if cfg!(target_os = "linux") { + assert!(names.iter().any(|n| *n == "linux-x64")); + } else if cfg!(target_os = "windows") { + assert!(names.iter().any(|n| *n == "windows-x64")); + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + assert!(names.iter().any(|n| *n == "macos-arm64")); + } + } } From e4acf56c92d88560d70afa3d8f982f595f613f4f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 07:27:31 +0000 Subject: [PATCH 25/50] Wrap markdown selections and make GPUI tests the product gate. Toolbar and slash commands now wrap the current editor selection instead of inserting empty marks at the caret. Notes gets the same slash menu as the markdown viewer, and a failed file open shows the inventory error pane. CI runs the GPUI test suite on PRs; leftover React checks are manual only. Co-authored-by: AkaraChen --- .github/workflows/gpui-check.yml | 4 +- .github/workflows/unit-tests.yml | 4 +- src-gpui/src/app.rs | 99 ++++++++++++++++++++++++++++---- src-gpui/src/state.rs | 1 + src-gpui/src/ui/file_viewer.rs | 30 ++++++---- src-gpui/src/ui/notes.rs | 55 ++++++++++++++++++ 6 files changed, 166 insertions(+), 27 deletions(-) diff --git a/.github/workflows/gpui-check.yml b/.github/workflows/gpui-check.yml index 6ce69e42..dd2ae21a 100644 --- a/.github/workflows/gpui-check.yml +++ b/.github/workflows/gpui-check.yml @@ -39,5 +39,5 @@ jobs: workspaces: | src-gpui -> target - - name: Check GPUI crate - run: cargo check --tests --manifest-path src-gpui/Cargo.toml + - name: Test GPUI crate + run: cargo test --manifest-path src-gpui/Cargo.toml diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index a47cc7bf..3022bc61 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -8,10 +8,12 @@ on: pull_request: branches: - dev + workflow_dispatch: jobs: frontend: - name: Legacy frontend + name: Leftover React (manual) + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-24.04 steps: diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index da280592..2ac531d7 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -2,7 +2,10 @@ use std::collections::{HashMap, HashSet}; use std::path::Path; use std::time::Duration; -use gpui::{div, prelude::*, px, Action, App, Context, Entity, FocusHandle, KeyBinding, Timer, Window, WindowHandle}; +use gpui::{ + div, prelude::*, px, Action, App, Context, Entity, EntityInputHandler, FocusHandle, KeyBinding, Timer, Window, + WindowHandle, +}; #[derive(Clone, PartialEq, Default, Debug, Action)] #[action(namespace = twocode, no_json)] @@ -547,10 +550,13 @@ impl AppView { pub fn open_file(&mut self, profile_id: &str, path: &str, window: &mut Window, cx: &mut Context) { let previewable = backend::is_previewable(path); - let content = if previewable { - String::new() + let (content, load_error) = if previewable { + (String::new(), None) } else { - self.backend.read_file(profile_id, path).unwrap_or_default() + match self.backend.read_file(profile_id, path) { + Ok(content) => (content, None), + Err(err) => (String::new(), Some(err.to_string())), + } }; let preview = if previewable { self.backend.file_preview(profile_id, path).ok() @@ -587,6 +593,7 @@ impl AppView { binary_note: preview.map(|p| p.mime_type).unwrap_or_default(), preview_path, archive_entries, + load_error, }; ws.files.push(tab); ws.active = Some(UnifiedTab::File { @@ -2597,6 +2604,53 @@ pub fn offset_line_col(text: &str, offset: usize) -> (u32, u32) { (line, character) } +pub fn wrap_markup_text(text: &str, start: usize, end: usize, prefix: &str, suffix: &str) -> (String, usize) { + let start = snap_char_boundary(text, start.min(text.len())); + let end = snap_char_boundary(text, end.min(text.len())); + let (a, b) = if start <= end { (start, end) } else { (end, start) }; + let mut out = String::with_capacity(text.len() + prefix.len() + suffix.len()); + out.push_str(&text[..a]); + out.push_str(prefix); + out.push_str(&text[a..b]); + out.push_str(suffix); + out.push_str(&text[b..]); + (out, a + prefix.len() + (b - a)) +} + +fn snap_char_boundary(text: &str, mut offset: usize) -> usize { + while offset > 0 && !text.is_char_boundary(offset) { + offset -= 1; + } + offset +} + +fn utf16_offset_to_bytes(text: &str, utf16: usize) -> usize { + let mut seen = 0; + for (byte, ch) in text.char_indices() { + if seen >= utf16 { + return byte; + } + seen += ch.len_utf16(); + } + text.len() +} + +pub fn apply_slash_command(text: &str, prefix: &str, suffix: &str) -> String { + let mut lines: Vec<&str> = text.lines().collect(); + if let Some(last) = lines.last_mut() { + if last.starts_with('/') { + *last = ""; + } + } + let mut next = lines.join("\n"); + if !next.is_empty() && !next.ends_with('\n') { + next.push('\n'); + } + next.push_str(prefix); + next.push_str(suffix); + next +} + pub fn wrap_markup( input: &Entity, prefix: &str, @@ -2606,12 +2660,17 @@ pub fn wrap_markup( ) { input.update(cx, |state, cx| { let text = state.value().to_string(); - let mut cursor = state.cursor().min(text.len()); - while cursor > 0 && !text.is_char_boundary(cursor) { - cursor -= 1; - } - let new_text = format!("{}{prefix}{suffix}{}", &text[..cursor], &text[cursor..]); - let caret = cursor + prefix.len(); + let (start, end) = match state.selected_text_range(true, window, cx) { + Some(sel) => ( + utf16_offset_to_bytes(&text, sel.range.start), + utf16_offset_to_bytes(&text, sel.range.end), + ), + None => { + let cursor = snap_char_boundary(&text, state.cursor().min(text.len())); + (cursor, cursor) + } + }; + let (new_text, caret) = wrap_markup_text(&text, start, end, prefix, suffix); state.set_value(new_text.clone(), window, cx); let (line, character) = offset_line_col(&new_text, caret); state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); @@ -2621,8 +2680,8 @@ pub fn wrap_markup( #[cfg(test)] mod tests { use super::{ - file_status_badge, git_status_kind, offset_line_col, search_match_offsets, suggested_project_name, - unique_tree_name, GitStatusKind, + apply_slash_command, file_status_badge, git_status_kind, offset_line_col, search_match_offsets, + suggested_project_name, unique_tree_name, wrap_markup_text, GitStatusKind, }; #[test] @@ -2667,4 +2726,20 @@ mod tests { assert!(search_match_offsets("abc", "").is_empty()); assert!(search_match_offsets("abc", "z").is_empty()); } + + #[test] + fn wrap_markup_text_wraps_selection_or_caret() { + assert_eq!(wrap_markup_text("hello", 2, 2, "**", "**"), ("he****llo".into(), 4)); + assert_eq!(wrap_markup_text("hello", 1, 4, "*", "*"), ("h*ell*o".into(), 5)); + assert_eq!(wrap_markup_text("hello", 4, 1, "*", "*"), ("h*ell*o".into(), 5)); + } + + #[test] + fn apply_slash_command_replaces_the_slash_line() { + assert_eq!(apply_slash_command("/h1", "# ", ""), "# "); + assert_eq!( + apply_slash_command("intro\n/code", "```\n", "\n```"), + "intro\n```\n\n```" + ); + } } diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 32c2fee0..98b7d1b7 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -216,6 +216,7 @@ pub struct OpenFileTab { pub binary_note: String, pub preview_path: String, pub archive_entries: Vec<(String, String)>, + pub load_error: Option, } impl OpenFileTab { diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 2afda3fc..9e7df18b 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -23,6 +23,23 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) return div().id("file-viewer-missing").into_any_element(); }; + if let Some(err) = file.load_error.clone() { + return v_flex() + .id("file-viewer-error") + .size_full() + .items_center() + .justify_center() + .h(px(128.)) + .gap_2() + .child( + div() + .text_color(theme.muted_foreground) + .child(app.t("somethingWentWrong")), + ) + .child(div().text_xs().text_color(theme.muted_foreground).child(err)) + .into_any_element(); + } + if file.preview { return preview_pane(app, &file, cx).into_any_element(); } @@ -462,18 +479,7 @@ fn slash_menu(query: &str, view: &gpui::Entity, locale: crate::i18n::Lo .on_click(move |_, window, cx| { view.update(cx, |app, cx| { let text = app.inputs.file_editor.read(cx).value().to_string(); - let mut lines: Vec<&str> = text.lines().collect(); - if let Some(last) = lines.last_mut() { - if last.starts_with('/') { - *last = ""; - } - } - let mut next = lines.join("\n"); - if !next.is_empty() && !next.ends_with('\n') { - next.push('\n'); - } - next.push_str(prefix); - next.push_str(suffix); + let next = crate::app::apply_slash_command(&text, prefix, suffix); app.inputs.file_editor.update(cx, |s, cx| { s.set_value(next, window, cx); }); diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs index f0974df5..b061bfa3 100644 --- a/src-gpui/src/ui/notes.rs +++ b/src-gpui/src/ui/notes.rs @@ -17,6 +17,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) NotesStatus::Saved => app.t("notesSaved"), }; let draft = app.inputs.notes.read(cx).value().to_string(); + let slash = draft.lines().last().unwrap_or("").to_string(); v_flex() .id("notes-panel") @@ -76,6 +77,9 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) )) .child(note_md("n-hr", "—", "---\n", "", &view, &app.t("notesInsertDivider"))), ) + .when(slash.starts_with('/'), |el| { + el.child(notes_slash_menu(&slash, &view, app.data.locale)) + }) .child( v_flex() .id("notes-editor") @@ -100,6 +104,57 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) ) } +fn notes_slash_menu(query: &str, view: &gpui::Entity, locale: crate::i18n::Locale) -> impl IntoElement { + let q = query.trim_start_matches('/').to_ascii_lowercase(); + let items: [(&str, &str, &str); 9] = [ + ("h1", "# ", ""), + ("h2", "## ", ""), + ("h3", "### ", ""), + ("ul", "- ", ""), + ("quote", "> ", ""), + ("code", "```\n", "\n```"), + ("link", "[", "](url)"), + ("table", "| A | B |\n| --- | --- |\n| | |\n", ""), + ("hr", "---\n", ""), + ]; + v_flex() + .id("notes-slash") + .px_2() + .py_1() + .gap_1() + .w(px(180.)) + .max_h(px(280.)) + .child( + div() + .text_xs() + .text_color(gpui::hsla(0., 0., 0.5, 1.)) + .child(crate::i18n::t(locale, "notesCommandMenu")), + ) + .child( + h_flex().gap_1().flex_wrap().children( + items + .into_iter() + .filter(|(name, _, _)| q.is_empty() || name.contains(&q)) + .map(|(name, prefix, suffix)| { + let view = view.clone(); + Button::new(crate::ui::eid(format!("notes-slash-{name}"))) + .ghost() + .xsmall() + .label(format!("/{name}")) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + let text = app.inputs.notes.read(cx).value().to_string(); + let next = crate::app::apply_slash_command(&text, prefix, suffix); + app.inputs.notes.update(cx, |s, cx| { + s.set_value(next, window, cx); + }); + }); + }) + }), + ), + ) +} + fn note_md( id: &'static str, label: &'static str, From 7b5c54824970f0489853460772ea6b1384f60ad1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 07:29:27 +0000 Subject: [PATCH 26/50] Replace GPUI binaries in place and drop leftover Tauri from default commands. Install now overwrites the running 2code binary for linux/macOS/Windows GPUI assets instead of only opening a downloaded installer. Dirty git paths still appear in an unscanned file tree, and just start / tauri:dev / verify all go through GPUI tests rather than leftover React. Co-authored-by: AkaraChen --- README.md | 7 ++-- justfile | 12 +++---- package.json | 4 ++- src-gpui/src/app.rs | 33 +---------------- src-gpui/src/state.rs | 71 +++++++++++++++++++++++++++++++++++++ src-gpui/src/updater.rs | 78 +++++++++++++++++++++++++++++++++++++++-- 6 files changed, 161 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 0c6fe407..07b1c137 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,8 @@ The GPUI app uses the same SQLite database as the old Tauri shell: `$XDG_DATA_HO ```bash bun install -bun run legacy:tauri:dev +just leftover-tauri +# or: bun run leftover:tauri:dev ``` ### Build @@ -87,11 +88,11 @@ Tagged releases publish GPUI binaries (`2code-linux-x64`, `2code-macos-arm64`, ` | --- | --- | | `just start` / `./start.sh` | Run the GPUI desktop app | | `just gpui` | Run the GPUI desktop app | -| `just gpui-check` | Type-check the GPUI crate | +| `just gpui-check` / `just verify` | Run GPUI unit tests, then domain crate tests | | `cd src-tauri && cargo test` | Run Rust domain tests | | `just fmt` | Format TypeScript and Rust | | `just coverage` | Generate Rust coverage report | -| `bun run legacy:tauri:dev` | Run the leftover Tauri + React shell | +| `just leftover-tauri` | Run the leftover Tauri + React shell | ## Project Structure diff --git a/justfile b/justfile index c61c2ed9..17c68549 100644 --- a/justfile +++ b/justfile @@ -3,13 +3,16 @@ gpui: cd src-gpui && cargo run gpui-check: - cd src-gpui && cargo check --tests + cd src-gpui && cargo test start: just gpui tauri-dev: - bun tauri dev + just gpui + +leftover-tauri: + bun run leftover:tauri:dev fmt: fama "./src/**/*.{ts,tsx}" @@ -23,14 +26,11 @@ test-rust: cd src-tauri && cargo test test-all: - bun run test + just gpui-check just test-rust verify: just gpui-check - bun run lint:check - bun run typecheck - bun run test just test-rust coverage: diff --git a/package.json b/package.json index 397dc1c4..c22af029 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "preview": "vite preview", "tauri": "tauri", "start": "just gpui", - "tauri:dev": "tauri dev", + "tauri:dev": "just gpui", + "leftover:tauri:dev": "tauri dev", "legacy:tauri:dev": "tauri dev", + "leftover:tauri:build": "tauri build", "legacy:tauri:build": "tauri build", "lint": "eslint --fix", "lint:check": "eslint . --max-warnings=0", diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 2ac531d7..38957c13 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -489,39 +489,8 @@ impl AppView { let Some(ws) = self.data.workspaces.get_mut(profile_id) else { return; }; - if !ws.tree.contains_key("") { - return; - } let files: Vec = ws.git_files.iter().map(|(p, _)| p.clone()).collect(); - for path in files { - if path.is_empty() || ws.tree.contains_key(&path) { - continue; - } - let mut parent = String::new(); - let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); - for (i, part) in parts.iter().enumerate() { - let current = if parent.is_empty() { - (*part).to_string() - } else { - format!("{parent}/{part}") - }; - let is_last = i + 1 == parts.len(); - ws.tree.entry(current.clone()).or_insert_with(|| TreeNode { - path: current.clone(), - name: (*part).to_string(), - is_dir: !is_last, - expanded: false, - children_loaded: is_last, - children: Vec::new(), - }); - if let Some(pnode) = ws.tree.get_mut(&parent) { - if !pnode.children.contains(¤t) { - pnode.children.push(current.clone()); - } - } - parent = current; - } - } + crate::state::inject_git_paths(&mut ws.tree, files); } pub fn toggle_dir(&mut self, profile_id: &str, path: &str) { diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 98b7d1b7..8b4a6add 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -330,6 +330,43 @@ pub struct TreeNode { pub children: Vec, } +/// Inventory §8.1: dirty/untracked paths show even when their parent was never scanned. +pub fn inject_git_paths(tree: &mut HashMap, files: impl IntoIterator>) { + if !tree.contains_key("") { + return; + } + for path in files { + let path = path.as_ref(); + if path.is_empty() || tree.contains_key(path) { + continue; + } + let mut parent = String::new(); + let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect(); + for (i, part) in parts.iter().enumerate() { + let current = if parent.is_empty() { + (*part).to_string() + } else { + format!("{parent}/{part}") + }; + let is_last = i + 1 == parts.len(); + tree.entry(current.clone()).or_insert_with(|| TreeNode { + path: current.clone(), + name: (*part).to_string(), + is_dir: !is_last, + expanded: false, + children_loaded: is_last, + children: Vec::new(), + }); + if let Some(pnode) = tree.get_mut(&parent) { + if !pnode.children.contains(¤t) { + pnode.children.push(current.clone()); + } + } + parent = current; + } + } +} + pub struct Workspace { pub project_id: String, pub profile_id: String, @@ -605,6 +642,40 @@ pub fn collect_sidebar_nav_items( mod tests { use super::*; + #[test] + fn inject_git_paths_adds_unscanned_dirty_files() { + let mut tree = HashMap::new(); + tree.insert( + String::new(), + TreeNode { + path: String::new(), + name: String::new(), + is_dir: true, + expanded: true, + children_loaded: true, + children: vec!["src".into()], + }, + ); + tree.insert( + "src".into(), + TreeNode { + path: "src".into(), + name: "src".into(), + is_dir: true, + expanded: false, + children_loaded: false, + children: Vec::new(), + }, + ); + inject_git_paths(&mut tree, ["src/new.rs", "notes.md"]); + assert!(tree.contains_key("src/new.rs")); + assert!(tree.contains_key("notes.md")); + assert!(tree["src"].children.contains(&"src/new.rs".to_string())); + assert!(tree[""].children.contains(&"notes.md".to_string())); + assert!(!tree["src/new.rs"].is_dir); + assert!(tree["src"].is_dir); + } + #[test] fn empty_sidebar_starts_at_home() { assert_eq!( diff --git a/src-gpui/src/updater.rs b/src-gpui/src/updater.rs index b959691d..905560fc 100644 --- a/src-gpui/src/updater.rs +++ b/src-gpui/src/updater.rs @@ -174,8 +174,73 @@ pub fn download_and_install(accept_beta: bool) -> Result { let mut reader = response.into_reader(); std::io::copy(&mut reader, &mut file).map_err(|e| e.to_string())?; file.flush().map_err(|e| e.to_string())?; - open::that(&dest).map_err(|e| e.to_string())?; - Ok(dest.display().to_string()) + match install_kind(name) { + InstallKind::ReplaceRunningBinary => { + replace_running_binary(&dest)?; + Ok(dest.display().to_string()) + } + InstallKind::OpenInstaller => { + open::that(&dest).map_err(|e| e.to_string())?; + Ok(dest.display().to_string()) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallKind { + ReplaceRunningBinary, + OpenInstaller, +} + +pub fn install_kind(asset_name: &str) -> InstallKind { + let name = asset_name.to_ascii_lowercase(); + if name.contains("2code-linux") || name.contains("2code-macos") || name.contains("2code-windows") { + return InstallKind::ReplaceRunningBinary; + } + if name.ends_with(".dmg") + || name.ends_with(".pkg") + || name.ends_with(".msi") + || name.ends_with(".appimage") + || name.ends_with(".deb") + || name.ends_with(".rpm") + { + return InstallKind::OpenInstaller; + } + InstallKind::ReplaceRunningBinary +} + +fn replace_running_binary(downloaded: &std::path::Path) -> Result<(), String> { + let current = std::env::current_exe().map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(downloaded).map_err(|e| e.to_string())?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(downloaded, perms).map_err(|e| e.to_string())?; + } + let staged = current.with_file_name(format!( + "{}.update", + current.file_name().and_then(|n| n.to_str()).unwrap_or("2code") + )); + std::fs::copy(downloaded, &staged).map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&staged).map_err(|e| e.to_string())?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&staged, perms).map_err(|e| e.to_string())?; + } + match std::fs::rename(&staged, ¤t) { + Ok(()) => Ok(()), + Err(_) => { + let backup = current.with_extension("old"); + std::fs::rename(¤t, &backup).map_err(|e| e.to_string())?; + std::fs::rename(&staged, ¤t).map_err(|e| { + let _ = std::fs::rename(&backup, ¤t); + e.to_string() + }) + } + } } fn asset_url(accept_beta: bool) -> Result { @@ -304,4 +369,13 @@ mod tests { assert!(names.iter().any(|n| *n == "macos-arm64")); } } + + #[test] + fn gpui_binaries_replace_in_place() { + assert_eq!(install_kind("2code-linux-x64"), InstallKind::ReplaceRunningBinary); + assert_eq!(install_kind("2code-macos-arm64"), InstallKind::ReplaceRunningBinary); + assert_eq!(install_kind("2code-windows-x64.exe"), InstallKind::ReplaceRunningBinary); + assert_eq!(install_kind("2code_3.0.1_aarch64.dmg"), InstallKind::OpenInstaller); + assert_eq!(install_kind("2code-setup.msi"), InstallKind::OpenInstaller); + } } From 6652f36ef75dd567bb3e812c139a0b670a793a16 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 07:36:00 +0000 Subject: [PATCH 27/50] Match GPUI file marks and leftover product docs to the inventory. Language badges and extra file kinds distinguish the tree, tabs, palette, and Git list. Home uses a FolderPlus empty state, terminal search is 224px, Top Bar reordering is drag-only, and agent docs now describe GPUI as the product. Co-authored-by: AkaraChen --- AGENTS.md | 98 ++++++++--------- CLAUDE.md | 196 +++++++++++++-------------------- src-gpui/src/ui/file_icons.rs | 169 ++++++++++++++++++++++------ src-gpui/src/ui/file_tree.rs | 36 +++--- src-gpui/src/ui/file_viewer.rs | 6 +- src-gpui/src/ui/git.rs | 6 +- src-gpui/src/ui/home.rs | 22 +++- src-gpui/src/ui/palette.rs | 8 +- src-gpui/src/ui/settings.rs | 38 +------ src-gpui/src/ui/terminal.rs | 3 +- src-gpui/src/ui/workspace.rs | 2 +- 11 files changed, 306 insertions(+), 278 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8ef1757b..c42390b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,88 +1,84 @@ # AGENTS.md — 2code -**Generated:** 2026-04-09 | **Commit:** 93661da | **Branch:** dev +**Generated:** 2026-04-09 | **Branch:** cursor/gpui-rewrite-ee3c ## OVERVIEW -Tauri 2 desktop app for managing code projects with integrated PTY terminals. React 19 + TS frontend, Rust workspace backend, SQLite via Diesel. +GPUI desktop app for managing code projects with integrated PTY terminals. Product UI is `src-gpui/` calling Rust domain crates directly. Leftover Tauri 2 + React 19 in `src/` / `src-tauri/` is reference only. SQLite via Diesel. ## STRUCTURE ``` 2code/ -├── src/ # React 19 + Vite frontend -│ ├── features/ # Feature-first: debug git home profiles projects settings terminal topbar watcher -│ ├── shared/ # lib/ providers/ components/ hooks/ -│ ├── layout/ # AppSidebar + sidebar/ sub-components -│ ├── generated/ # AUTO-GENERATED Tauri IPC bindings (DO NOT EDIT, gitignored) -│ └── paraglide/ # AUTO-GENERATED i18n messages (DO NOT EDIT, gitignored) +├── src-gpui/ # GPUI product UI (bin: 2code) +│ └── src/ui/ # sidebar home workspace tree terminal git settings dialogs ├── src-tauri/ -│ ├── src/handler/ # #[tauri::command] entry points (8 files) │ ├── crates/infra/src/ # DB, PTY, git, shell init, watcher, logger, slug │ ├── crates/service/src/ # Business logic: project, profile, pty, watcher │ ├── crates/repo/src/ # Diesel CRUD: project, profile, pty │ ├── crates/model/src/ # DTOs, Diesel models, error types +│ ├── src/handler/ # Leftover Tauri #[tauri::command] entry points │ └── migrations/ # Diesel SQL migrations (embedded at compile time) -├── messages/ # i18n source: en.json zh.json -└── justfile # Build helpers: coverage, fmt +├── src/ # Leftover React 19 + Vite frontend (not shipping) +├── messages/ # i18n source: en.json zh.json (GPUI loads these) +├── docs/ui-inventory.md # Visual/behavior rewrite spec +└── justfile # start / gpui / verify / leftover-tauri ``` ## WHERE TO LOOK | Task | Location | |------|----------| -| Add Tauri command | `src-tauri/src/handler/*.rs` → register in `lib.rs` → run `cargo tauri-typegen generate` | -| Consume IPC in frontend | Import from `@/generated` → wrap in TanStack Query hook | -| Query keys | `src/shared/lib/queryKeys.ts` — always use this, never inline strings | -| Terminal tabs/state | `src/features/terminal/store.ts` (Zustand + Immer) | +| Product window / actions | `src-gpui/src/app.rs` + `src-gpui/src/main.rs` | +| Call domain services | `src-gpui/src/backend.rs` (no IPC) | +| Terminal tabs / PTY grid | `src-gpui/src/ui/terminal.rs` + `src-gpui/src/state.rs` | +| File tree / icons | `src-gpui/src/ui/file_tree.rs` + `file_icons.rs` | +| Settings window | `src-gpui/src/ui/settings.rs` | +| i18n | `messages/en.json` + `zh.json` → `src-gpui/src/i18n.rs` | +| Agent status detection | `src-gpui/src/detector/` | | PTY session lifecycle | `src-tauri/crates/infra/src/pty.rs` + `crates/service/src/pty.rs` | | DB migrations | `src-tauri/migrations/` (Diesel; auto-applied on startup) | -| Git operations | `src-tauri/crates/infra/src/git.rs` + `src-tauri/src/handler/debug.rs` | -| Context ID resolution | `crates/repo/src/project.rs::resolve_context_folder` (polymorphic project/profile) | +| Git operations | `src-tauri/crates/infra/src/git.rs` | +| Context ID resolution | `crates/repo/src/project.rs::resolve_context_folder` | | Worktree profiles | `crates/service/src/profile.rs` — creates `~/.2code/workspace/{id}` | -| Agent status detection | `src/features/terminal/detector/` → `Terminal.tsx` → terminalStore | -| i18n messages | `messages/en.json` + `messages/zh.json` → `import * as m from "@/paraglide/messages.js"` | | Shell init injection | `infra/shell_init.rs` (ZDOTDIR-based) | +| Leftover Tauri command | `src-tauri/src/handler/*.rs` — do not add product features here | ## COMMANDS ```bash -bun tauri dev # full dev (frontend + Rust hot reload) -bun run dev # frontend only -bun tauri build # production build -cd src-tauri && cargo test # Rust tests -cargo tauri-typegen generate # regenerate src/generated/ after Rust command changes -just fmt # format TS + Rust -just coverage # llvm-cov HTML report +just start # GPUI product UI +just gpui # cd src-gpui && cargo run +just verify # cargo test (GPUI) + cargo test (domain) +just leftover-tauri # leftover webview shell only +cd src-tauri && cargo test # domain crate tests +just fmt # format leftover TS + Rust ``` ## STATE PATTERNS -- **Server state**: TanStack Query — always invalidate on mutations -- **Client state**: Zustand with Immer — terminal store uses `Set` (requires `enableMapSet()`) -- **Persist**: `terminalSettingsStore`, `notificationStore`, `themeStore` use localStorage via `persist` middleware -- **Outside React**: `useTerminalStore.getState().addTab(...)` (direct access, no hook) +- **GPUI**: `AppView` + `AppData` / `Overlay` in `state.rs`; prefs in `gpui-prefs.json` +- **Domain**: Diesel via `Backend` (single SQLite mutex) +- **Leftover React**: TanStack Query + Zustand — do not extend for product work ## KEY PATTERNS -- **IPC flow**: Rust `#[tauri::command]` → `tauri-typegen` → `src/generated/` → TanStack Query hook -- **Terminal persistence**: CSS `display: none` on tab switch — NEVER unmount or conditionally render terminals -- **Context ID**: git handlers accept project ID or profile ID — backend resolves via `resolve_context_folder` -- **Rust test setup**: in-memory SQLite + `conn.run_pending_migrations(MIGRATIONS)` in `setup_db()` -- **DB lock**: single `Arc>` — acquire/release quickly, never hold across awaits +- **No IPC in the product UI**: GPUI calls `service::*` / `repo::*` on the same process +- **Terminal persistence**: keep every `pty-{id}` mounted; hide inactive sessions +- **Context ID**: git accepts project ID or profile ID — `resolve_context_folder` +- **Rust test setup**: in-memory SQLite + `conn.run_pending_migrations(MIGRATIONS)` +- **DB lock**: single `Arc>` — acquire/release quickly ## ANTI-PATTERNS -- `src/api/` — forbidden; all IPC via `src/generated/` auto-gen -- `src/generated/` or `src/paraglide/` — DO NOT EDIT (gitignored, regenerated) -- `src-tauri/src/schema.rs` — DO NOT EDIT (Diesel generated) -- Conditional rendering of `` — breaks xterm.js state -- Legacy UI-library APIs/components — removed; use shadcn/ui primitives from `src/components/ui` -- Long-held DB mutex locks — causes deadlocks -- Font listing / sound APIs without macOS platform guard (macOS-only) +- Treating `src/` React or `bun tauri dev` as the product +- Dropping `pty-{id}` from the element tree (GPUI unmounts the session) +- Unmounting the file tree when the profile sidebar closes (use width 0) +- Opening Settings as a route instead of a second window +- Wholesale rustfmt of `src-gpui/src/ui/settings.rs` or `dialogs.rs` +- Editing `src-tauri/src/schema.rs` (Diesel generated) +- Long-held DB mutex locks ## GOTCHAS -- `src-tauri/src/main.rs:1` — `#![cfg_attr(…, windows_subsystem = "windows")]` has `DO NOT REMOVE!!` -- `topbar` feature is NOT part of `git` feature despite CLAUDE.md proximity — it's a separate customizable control bar system -- Immer `MapSet` plugin must be enabled before any store using `Set`/`Map` (already done in `store.ts`) -- `noUnusedLocals` + `noUnusedParameters` enforced in tsconfig — TS will error on unused vars -- CI: `.github/workflows/tauri-smoke.yml` — smoke test on `ubuntu-24.04` using `xvfb-run` (virtual display) + `webkit2gtk-driver` + Tauri driver. Not a full test suite. -- E2E: `e2e-tests/` uses Mocha + Selenium WebDriver via Tauri driver (not Playwright/Cypress) -- Frontend uses Vitest (`npm test` = `vitest run`); test files colocated as `*.test.ts` — Zustand store tests use `resetStore()` helper pattern -- ESLint uses `@antfu/eslint-config` with React flat config — configuration lives in `eslint.config.js` at the repo root +- rustc 1.87+; `cc = "=1.2.67"` pin in `src-gpui/Cargo.toml` +- `ElementId` via `ui::eid(...)` (`SharedString`), not `From` +- Safe rustfmt: `--edition 2021 --config hard_tabs=true,tab_spaces=4,max_width=120` +- Font listing / sound APIs are platform-backed +- CI product gate: `.github/workflows/gpui-check.yml` (`cargo test` in `src-gpui`) +- Release ships GPUI binaries (`2code-linux-x64` / `2code-macos-arm64` / `2code-windows-x64.exe`) +- Leftover `tauri-smoke.yml` is workflow_dispatch / workflow_call only - `openspec/` dir at root is OpenSpec workflow tooling — not application code -- `src-tauri/src/bridge.rs` — trait impls (`TauriPtyEmitter`, `TauriWatchSender`) that decouple service layer from Tauri diff --git a/CLAUDE.md b/CLAUDE.md index ecc4954c..e6d31e7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,158 +4,120 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**2code** is a Tauri 2 desktop application for managing code projects with integrated terminal sessions. It combines a React 19 frontend with a Rust backend, featuring: +**2code** is a GPUI desktop application for managing code projects with integrated PTY terminals. The shipping UI lives in `src-gpui/` and calls the existing Rust domain crates directly (no Tauri IPC). +- Two windows: main (1440×900 overlay chrome) and Settings (880×640) - Project management with folder selection and metadata - Profile management via git worktrees (branch-isolated workspaces) -- Persistent PTY (pseudo-terminal) sessions with scrollback restoration +- Persistent PTY sessions with scrollback restoration (never unmount on tab/route change) - SQLite database for project/session/profile storage - Project-level configuration (`2code.json`) for setup/teardown scripts - Git diff/commit history browsing -- i18n support via Paraglide.js (English + Chinese) +- i18n from `messages/en.json` + `messages/zh.json` (loaded directly by GPUI) + +The leftover Tauri 2 + React 19 shell in `src/` + `src-tauri/` is **not** the product. Domain crates under `src-tauri/crates/` are shared. Visual/behavior spec: `docs/ui-inventory.md`. ## Commands ```bash -# Dev server (frontend + backend hot-reload) -bun tauri dev - -# Frontend-only dev -bun run dev - -# Production build -bun tauri build - -# Frontend-only build (runs paraglide compile → tsc → vite build) -bun run build - -# Rust tests +# Product UI (GPUI) +just start # same as just gpui +just gpui # cd src-gpui && cargo run +cd src-gpui && cargo run + +# Tests / gate +just verify # gpui tests + domain crate tests +just gpui-check # cd src-gpui && cargo test cd src-tauri && cargo test -cd src-tauri && cargo test test_name # single test +cd src-tauri && cargo test test_name -# Regenerate TypeScript bindings from Rust commands -cargo tauri-typegen generate +# Leftover Tauri + React (reference only) +just leftover-tauri +bun run leftover:tauri:dev -# Format code -just fmt # runs 'fama' +# Format +just fmt # fama (TS leftover) + rustfmt ``` -## Architecture - -### Frontend (`/src`) - -React 19 + TypeScript + Vite. Provider stack (outermost → innermost): `QueryClientProvider` → `ThemeProvider` → `TooltipProvider` → `BrowserRouter` → `AppRoot`, with the shadcn `Toaster` mounted inside `TooltipProvider`. - -**Routing** (react-router v7): `/` → HomePage, `/projects/:id/profiles/:profileId` → ProjectDetailPage, `/settings` → SettingsPage, `*` → redirect to `/`. +App data matches the old Tauri path: `dirs::data_dir()/com.akrc.code` (`app.db` + `gpui-prefs.json`). -**Key directories (feature-based organization):** +## Architecture -- `generated/` — Auto-generated Tauri IPC bindings via `tauri-typegen` (gitignored, do not edit) -- `features/home/` — HomePage -- `features/projects/` — ProjectDetailPage, project hooks (`useProjects`, `useCreateProject`, `useProjectProfiles`, etc.) and dialogs (Create/Delete/Rename) -- `features/profiles/` — Profile hooks (`useCreateProfile`, `useDeleteProfile`) and dialogs -- `features/terminal/` — Terminal store, hooks (`useCreateTerminalTab`, `useCloseTerminalTab`, `useRestoreTerminals`, `useTerminalTheme`), themes, and components (Terminal, TerminalTabs, TerminalLayer, TerminalPreview) -- `features/git/` — GitDiffDialog, ProjectTopBar (git branch display + diff trigger), and components (ChangesFileList, CommitList, GitDiffPane, HistoryFileList) -- `features/settings/` — SettingsPage, picker components, and Zustand stores (`stores/terminalSettingsStore`, `stores/themeStore`, `stores/notificationStore`) -- `features/watcher/` — File system watcher hook (`useFileWatcher`) for live project updates via Tauri events -- `features/debug/` — Debug panel (Cmd+Shift+D toggle), debug logger, and stores (`debugStore`, `debugLogStore`) -- `shared/lib/` — Query client config, centralized query keys, cached promise utility -- `shared/providers/` — ThemeProvider -- `shared/components/` — Fallbacks (PageSkeleton, PageError, SidebarSkeleton), SidebarLink. ErrorBoundary is from `react-error-boundary` package. -- `layout/` — AppSidebar and `sidebar/` sub-components (ProjectMenuItem, ProfileList, ProfileItem) +### Product UI (`/src-gpui`) -**State management:** +Standalone crate (not in the `src-tauri` workspace), bin name `2code`. Stack: `gpui` + `gpui-component`, path-deps on `../src-tauri/crates/{model,repo,infra,service}`. -- Zustand for client state (terminal tabs per project, font preferences, notification settings) -- TanStack Query for server state (projects, sessions, profiles) -- Query keys centralized in `shared/lib/queryKeys.ts` — always use `queryKeys.projects.all` / `queryKeys.git.diff(profileId)` pattern -- `terminalSettingsStore`, `notificationStore`, and `themeStore` use persist middleware (localStorage). Terminal store is rebuilt from DB on startup. +**Key modules:** -**UI Framework:** +- `main.rs` — window chrome, tracing + debug log channel +- `app.rs` — `AppView` actions, shortcuts, orchestration +- `backend.rs` — direct service/repo calls (no IPC) +- `state.rs` — workspaces, overlays, dialogs, toasts +- `prefs.rs` — `gpui-prefs.json` (theme, fonts, top bar, templates, sidebar) +- `ui/` — sidebar, home, workspace, file tree, terminal, git, settings, dialogs, palette, debug +- `detector/` — agent status manifests (same rules as the leftover React detector) +- `i18n.rs` — `t` / `tf` over `messages/{en,zh}.json` +- `platform.rs` — fonts, sounds, installed apps +- `updater.rs` — GitHub releases + in-place GPUI binary replace -- shadcn/ui primitives in `src/components/ui` (Base UI + Tailwind CSS v4) -- `next-themes` for dark/light mode (wrapped in custom ThemeProvider) -- `sonner` for toast notifications +**Windows:** main 1440×900 overlay title bar, macOS traffic lights at (16, 24); Settings via `cx.open_window` 880×640, title `"Settings"`. -### Backend (`/src-tauri`) +**Shortcuts:** Cmd+, settings · Cmd+Shift+D debug · Cmd+K palette · Cmd+T/W terminal · Cmd+E sidebar · Cmd+G git diff · Cmd+S save · Cmd/Ctrl+Enter commit. -Rust application with Tauri 2. Entry: `main.rs` → `lib.rs`. +**Invariants:** -**Layered architecture** (4 layers): +- Terminals stay mounted: every session keeps a stable `pty-{id}` element. Hide inactive ones; do not omit the id for a frame. +- File tree stays mounted across Files/Git/Notes **and** when the profile sidebar is closed (width 0, not unmount). +- Settings is a second window, not a route. +- Brand text `"2Code"` is hardcoded. -1. **Handler** (`handler/`) — Tauri `#[tauri::command]` entry points. Extracts state (DbPool, PtySessionMap), acquires DB lock, delegates to service layer. Thin layer — no business logic. -2. **Service** (`service/`) — Business logic and orchestration. Coordinates between repository and infrastructure layers (e.g., creating temp dirs, initializing git repos, running scripts). -3. **Repository** (`repo/`) — Direct database access via Diesel ORM. CRUD operations and complex queries (e.g., `resolve_context_folder` tries profiles table first, falls back to projects). -4. **Infrastructure** (`infra/`) — Cross-cutting concerns: `db.rs` (SQLite setup + migrations), `git.rs` (git command execution), `pty.rs` (PTY session lifecycle), `pty_log.rs` (per-session output stored as `{app_data_dir}/pty_logs/{session_id}.log` files), `slug.rs` (CJK-aware slug generation), `config.rs` (project config loading + script execution), `logger.rs` (debug logging), `watcher.rs` (file system watching), `shell_init.rs` (ZDOTDIR-based shell init injection). +### Domain crates (`/src-tauri/crates`) -**Model** (`model/`) — Diesel models and DTOs: Queryable structs (`Project`, `Profile`, `PtySessionRecord`), Insertable structs (`NewProject`, `NewProfile`), AsChangeset structs (`UpdateProject`, `UpdateProfile`), and non-DB types (`GitCommit`, `GitAuthor`, `WatchEvent`, `LogEntry`). +No Tauri dependency. Four layers plus models: -**Database:** SQLite via Diesel ORM, single connection wrapped in `Arc>` (not a pool). Stored at `app_data_dir()/app.db`. Pragmas: WAL journal mode, foreign keys ON. Tables: `projects`, `profiles`, `pty_sessions` (session **metadata** only — output bytes live in per-session files, not the DB). +1. **Service** — business logic (project, profile, pty, watcher, filesystem) +2. **Repository** — Diesel CRUD; `resolve_context_folder` tries profiles then projects +3. **Infrastructure** — SQLite + migrations, git, PTY, pty_log, slug, config, logger, watcher, shell_init +4. **Model** — Diesel models and DTOs -**Database migrations:** Diesel migrations in `src-tauri/migrations/`, embedded at compile time via `diesel_migrations::embed_migrations!()` and run on app startup in `infra::db::init_db()`. Schema auto-generated in `src/schema.rs`. +**Database:** SQLite, single `Arc>` (not a pool) at `app_data_dir()/app.db`. WAL, foreign keys ON. Tables: `projects`, `profiles`, `pty_sessions` (metadata only — output bytes live in `pty_logs/{session_id}.log`). Migrations in `src-tauri/migrations/`, embedded at compile time. -**PTY output streaming:** Background thread reads 4KB chunks → sends raw PTY chunks to the active output sink registered by `attach_pty_output(sessionId, streamId)`. `stream_pty_output` owns a `tauri::ipc::Channel<&[u8]>` and sends each chunk with `on_output.send(chunk.as_slice())`; `detach_pty_output` must pass the same `streamId` so stale React cleanup cannot remove a newer stream for the same session. `Terminal.tsx` receives `ArrayBuffer` and writes `new Uint8Array(payload)` to xterm. Output produced before attach is recovered from the persisted log via `get_pty_session_history` (same seam the old global-event API had). Bytes are sent as-is — xterm.js decodes UTF-8 across writes, so no backend boundary splitting is needed. Session exit is still a low-volume global event (`pty-exit-{id}`). A separate persistence thread via mpsc channel with 32KB flush buffer appends raw bytes to the session's `pty_logs/{session_id}.log` file (see `infra::pty_log`). No byte cap — a log lives only for one session (removed on restore/close/delete) and scrollback is bounded on restore by the vt100 emulator (`sanitize_history`, 10k lines). Orphan logs are reaped on startup by `service::pty::gc_orphan_logs`. +**PTY:** `service::pty::create_session` + `PtyContext` + `PtyEventEmitter`. Restore: `mark_all_closed` → fetch sessions → `restore_session` → feed history into vt100 → attach. GPUI renders the vt100 screen; leftover React used xterm.js over a Tauri Channel. **Workspace crates:** `model/`, `repo/`, `service/`, and `infra/`. -**Agent status detection:** `Terminal.tsx` detects coding-agent state from xterm screen text, OSC title, and OSC progress after live output writes. Rules live in `src/features/terminal/detector/rules/`, one manifest per agent. The detector publishes `running|waiting|idle` to `terminalStore`; waiting status can play the configured system sound via the generated `playSystemSound` Tauri command. - -### IPC Pattern (Frontend ↔ Backend) - -The project uses **tauri-typegen** to auto-generate typed TypeScript bindings from Rust commands. Config in `tauri.conf.json` under `plugins.typegen` (output: `src/generated/`). +### Leftover Tauri + React (`/src`, `/src-tauri`) -**Adding a new command:** - -1. Define Rust command with `#[tauri::command]` in `handler/*.rs` -2. Register in `lib.rs` via `tauri::generate_handler![]` -3. Run `cargo tauri-typegen generate` to regenerate TypeScript bindings -4. Import generated function directly: `import { myCommand } from "@/generated"` -5. Consume via TanStack Query hook in the relevant `src/features/*/hooks.ts` with query invalidation on mutations - -**Do not** create manual API wrappers in `src/api/` — all IPC bindings are auto-generated. +Reference implementation of the old webview UI. Do not add product features there. Do not delete until a real-machine GUI pass against `docs/ui-inventory.md` §20. Default start/release/CI paths are GPUI. ## Key Patterns ### Terminal Persistence -Terminals never unmount — tab switches and route changes use CSS `display: none` to preserve xterm.js state. The `TerminalLayer` component renders as a persistent absolute-positioned overlay across all routes. +All PTY grids from every workspace stay in one layer with stable `pty-{id}` ids. Tab switches and profile changes hide inactive terminals; they must not be dropped from the element tree. **Session restoration on app start:** -1. Fetch all sessions from DB (including closed ones with scrollback) -2. Create new PTY session with same metadata -3. Pass old `session.id` as `restoreFrom` prop -4. Terminal component fetches history, writes to xterm, then deletes old record - -**Session cleanup:** `mark_all_open_sessions_closed()` runs both on startup (orphan cleanup) and on exit (graceful shutdown). +1. `mark_all_open_sessions_closed()` +2. Fetch sessions (including closed ones with scrollback) +3. `restore_session` with the old id +4. Feed persisted history into vt100, then attach live output ### Context ID Resolution -Git operations (`get_git_diff`, `get_git_log`, `get_commit_diff`) accept a `contextId` parameter that can be either a project ID or a profile ID. The backend resolves this polymorphically via `repo::project::resolve_context_folder()`: profile ID → profile's worktree path; project ID → project's folder. This lets git operations work seamlessly with both regular project folders and profile worktrees. +Git operations accept a project ID or a profile ID. `repo::project::resolve_context_folder()`: profile → worktree path; project → folder. ### Profile System (Git Worktrees) -Profiles create isolated branch workspaces using `git worktree add`. Each profile gets a worktree in `~/.2code/workspace/{profile_id}`. Branch names are sanitized (CJK → pinyin, special chars stripped). On creation, `setup_script` from `2code.json` runs in the worktree. On deletion, `teardown_script` runs, then the worktree and branch are removed. +`git worktree add` into `~/.2code/workspace/{profile_id}` (or the configured worktree dir). Branch names are sanitized (CJK → pinyin). `setup_script` / `teardown_script` from `2code.json` run on create/delete. ### Project Configuration (`2code.json`) -Projects can include a `2code.json` in their root folder: - ```json { "setup_script": ["npm install"], "teardown_script": ["rm -rf node_modules"] } ``` -Scripts execute via `sh -c` in the project/worktree directory. Used automatically during profile creation/deletion. - -### Zustand Store Convention - -```typescript -// Direct access in mutations (outside React): -useTerminalStore.getState().addTab(...) - -// Reactive subscriptions in components: -const tabs = useTerminalStore(s => s.profiles[profileId]?.tabs) -``` +Scripts execute via `sh -c` in the project/worktree directory. ### Rust Test Pattern @@ -170,37 +132,29 @@ fn setup_db() -> SqliteConnection { } ``` -Tests are colocated with implementation in `#[cfg(test)]` modules. +Tests are colocated with implementation in `#[cfg(test)]` modules. GPUI unit tests live in `src-gpui/src/**`. ## Internationalization (i18n) -Paraglide.js v2 with inlang message format plugin. Source messages in `messages/{locale}.json`. Generated code in `src/paraglide/` (gitignored, do not edit). - -**Usage:** `import * as m from "@/paraglide/messages.js"` → `m.home()` +Source messages in `messages/{locale}.json`. GPUI loads them in `src-gpui/src/i18n.rs` (`t` / `tf`). The leftover React shell still uses Paraglide (`src/paraglide/`, gitignored). -**Critical:** `project.inlang/settings.json` **must** include the modules array: +**Critical for leftover Paraglide:** `project.inlang/settings.json` **must** include: ```json "modules": ["https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js"] ``` -Without this, paraglide compiles but generates empty message files. Also requires `allowJs: true` in tsconfig.json. - -## Path Aliases - -`@/` maps to `src/` — configured in both `vite.config.ts` (resolve.alias) and `tsconfig.json` (paths). Keep them in sync. - ## Gotchas -- **Database is single-connection** (`Arc>`), not a pool — avoid long-held locks -- **Terminals use CSS display for show/hide** — do not refactor to conditional rendering or they lose xterm state -- **PTY output sends `&[u8]` over a per-session IPC `Channel`** (not global events; frontend receives `ArrayBuffer`) — xterm.js handles UTF-8 decoding across chunk boundaries, so no backend boundary splitting is done -- **Terminal font metrics must be measured on an attached canvas** — WebKit only resolves locally installed fonts for canvases that are in the document; detached canvases and `OffscreenCanvas` silently report fallback metrics. xterm 6 measures offscreen but paints via the DOM, so `src/features/terminal/lib/xtermMetricsPatch.ts` redirects both of its measurement surfaces (`CharSizeService` and `WidthCache`) to attached canvases. Without it the terminal leaves ~1/6 of its width empty with any font whose advance ratio differs from the fallback's (e.g. Sarasa's 0.5 em vs Menlo's 0.6 em). See `src/features/terminal/AGENTS.md`. -- **Font listing and sound playback are platform-backed**: macOS uses `core-text` + `/System/Library/Sounds` + `afplay`; Linux uses `fontdb` + XDG sound dirs + desktop audio players; Windows uses `fontdb` + `C:\Windows\Media` + PowerShell `Media.SoundPlayer`. -- **UI components** should use shadcn/ui primitives from `src/components/ui`; do not add legacy UI-library APIs back -- **Directory/branch name generation** uses `pinyin` crate for CJK → romanized slugs — well-tested, don't simplify -- **macOS title bar** uses overlay style with custom traffic light positioning — window chrome is defined in `tauri.conf.json` -- **Tauri plugins**: `tauri-plugin-opener`, `tauri-plugin-dialog`, `tauri-plugin-notification`, `tauri-plugin-store` — all registered in `lib.rs` -- **Generated bindings** (`src/generated/`) are gitignored — run `cargo tauri-typegen generate` after changing Rust commands -- **Diesel schema** (`src-tauri/src/schema.rs`) is auto-generated — do not edit manually; run `diesel print-schema` or migrations -- **Immer MapSet plugin** — terminal store uses `Set` for `notifiedTabs`, requires `enableMapSet()` from immer before store creation. Already called at module level in `store.ts`; if adding `Set`/`Map` to other immer stores, enable it there too +- **Database is single-connection** — acquire/release quickly, never hold across awaits +- **Do not drop `pty-{id}`** — GPUI persistence is by ElementId; omitting an id unmounts the session +- **Do not rustfmt `src-gpui/src/ui/settings.rs` or `dialogs.rs` wholesale** — `settings.rs` has `rustfmt_skip`; both explode under default rustfmt +- Safe rustfmt: `rustfmt --edition 2021 --config hard_tabs=true,tab_spaces=4,max_width=120` on edited files only +- **`ElementId` does not impl `From`** — use `ui::eid(...)` (`SharedString`) +- **`div().overflow_y_scroll()` and `div().tooltip(...)` do not exist** — Button has `.tooltip(String)` +- **`InputState::selected_text` is `pub(super)`** — use `EntityInputHandler::selected_text_range` (UTF-16) +- Font listing / sound playback are platform-backed (core-text / fontdb / XDG / Windows Media) +- Directory/branch slugs use the `pinyin` crate — don't simplify +- Diesel schema `src-tauri/src/schema.rs` is auto-generated — do not edit +- `src/` / `src/generated/` / `src/paraglide/` are leftover React — do not treat as the product UI +- rustc **1.87+**; pin **`cc = "=1.2.67"`** in `src-gpui/Cargo.toml` diff --git a/src-gpui/src/ui/file_icons.rs b/src-gpui/src/ui/file_icons.rs index 58aca102..6caaa2a8 100644 --- a/src-gpui/src/ui/file_icons.rs +++ b/src-gpui/src/ui/file_icons.rs @@ -1,8 +1,9 @@ -use gpui_component::IconName; +use gpui::{div, prelude::*, px, rgb, AnyElement, IntoElement}; +use gpui_component::{Icon, IconName, StyledExt}; -/// Language-colored file-type icon used by the tree, tabs, palette, and Git list. -/// Pierre's SVG sprite is not available in GPUI; this maps the same extensions -/// onto Lucide names plus a token color so types stay distinguishable. +/// Language-colored file-type mark used by the tree, tabs, palette, and Git list. +/// Pierre's SVG sprite is not available in GPUI; Lucide names plus a two-letter +/// language badge keep types distinguishable the way the complete icon set did. pub fn file_icon(path: &str, is_dir: bool, expanded: bool) -> IconName { if is_dir { return if expanded { @@ -12,13 +13,18 @@ pub fn file_icon(path: &str, is_dir: bool, expanded: bool) -> IconName { }; } match file_kind(path) { - FileKind::Markdown | FileKind::Text => IconName::BookOpen, + FileKind::Markdown => IconName::BookOpen, + FileKind::Text => IconName::File, FileKind::Config => IconName::Settings, FileKind::Git => IconName::GitHub, FileKind::Shell => IconName::SquareTerminal, FileKind::Image => IconName::GalleryVerticalEnd, - FileKind::Code => IconName::File, - FileKind::Other => IconName::File, + FileKind::Html => IconName::Globe, + FileKind::Css => IconName::Palette, + FileKind::Archive => IconName::Frame, + FileKind::Pdf => IconName::File, + FileKind::Lock => IconName::Asterisk, + FileKind::Code | FileKind::Other => IconName::File, } } @@ -33,11 +39,38 @@ pub fn file_icon_color(path: &str, is_dir: bool) -> u32 { FileKind::Git => 0xf05032, FileKind::Shell => 0x89e051, FileKind::Image => 0xa074c4, + FileKind::Html => 0xe34c26, + FileKind::Css => 0x563d7c, + FileKind::Archive => 0xcb8b41, + FileKind::Pdf => 0xe23b2d, + FileKind::Lock => 0x8b949e, FileKind::Code => code_color(path), FileKind::Other => 0x8b949e, } } +pub fn file_glyph(path: &str, is_dir: bool, expanded: bool, size: f32) -> AnyElement { + if is_dir || !matches!(file_kind(path), FileKind::Code) { + return Icon::new(file_icon(path, is_dir, expanded)) + .w(px(size)) + .text_color(rgb(file_icon_color(path, is_dir))) + .into_any_element(); + } + let (label, color) = lang_mark(path); + div() + .size(px(size)) + .rounded_sm() + .bg(rgb(color)) + .text_color(gpui::white()) + .flex() + .items_center() + .justify_center() + .text_size(px((size * 0.55).max(7.))) + .font_semibold() + .child(label) + .into_any_element() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum FileKind { Markdown, @@ -46,22 +79,32 @@ pub(crate) enum FileKind { Git, Shell, Image, + Html, + Css, + Archive, + Pdf, + Lock, Code, Other, } pub(crate) fn file_kind(path: &str) -> FileKind { - let name = std::path::Path::new(path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(path) - .to_ascii_lowercase(); + let name = file_name(path); if matches!( name.as_str(), ".gitignore" | ".gitattributes" | ".gitmodules" | ".gitkeep" ) { return FileKind::Git; } + if matches!( + name.as_str(), + "dockerfile" | "docker-compose.yml" | "docker-compose.yaml" + ) { + return FileKind::Shell; + } + if name.ends_with(".lock") || name == "bun.lockb" || name == "package-lock.json" || name == "pnpm-lock.yaml" { + return FileKind::Lock; + } let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or(""); match ext { "md" | "mdx" | "markdown" => FileKind::Markdown, @@ -69,38 +112,67 @@ pub(crate) fn file_kind(path: &str) -> FileKind { "json" | "jsonc" | "toml" | "yml" | "yaml" | "ini" | "env" | "editorconfig" => FileKind::Config, "sh" | "zsh" | "bash" | "fish" | "ps1" | "bat" | "cmd" => FileKind::Shell, "png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "bmp" | "ico" | "avif" => FileKind::Image, + "html" | "htm" => FileKind::Html, + "css" | "scss" | "less" => FileKind::Css, + "zip" | "tar" | "gz" | "tgz" | "rar" | "7z" | "bz2" | "xz" => FileKind::Archive, + "pdf" => FileKind::Pdf, "rs" | "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" | "py" | "go" | "java" | "kt" | "c" | "cc" | "cpp" | "cxx" | "h" | "hpp" | "cs" | "rb" | "php" | "swift" | "scala" | "lua" | "zig" | "nim" | "ex" | "exs" - | "erl" | "hs" | "ml" | "clj" | "dart" | "vue" | "svelte" | "css" | "scss" | "less" | "html" | "htm" - | "xml" | "sql" | "graphql" | "proto" => FileKind::Code, + | "erl" | "hs" | "ml" | "clj" | "dart" | "vue" | "svelte" | "sql" | "graphql" | "proto" => FileKind::Code, _ => FileKind::Other, } } -fn code_color(path: &str) -> u32 { - let ext = std::path::Path::new(path) - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_ascii_lowercase(); +pub(crate) fn lang_mark(path: &str) -> (&'static str, u32) { + let ext = file_name(path) + .rsplit_once('.') + .map(|(_, e)| e.to_string()) + .unwrap_or_default(); match ext.as_str() { - "rs" => 0xdea584, - "ts" | "tsx" => 0x3178c6, - "js" | "jsx" | "mjs" | "cjs" => 0xf1e05a, - "py" => 0x3572a5, - "go" => 0x00add8, - "java" | "kt" => 0xb07219, - "c" | "h" => 0x555555, - "cc" | "cpp" | "cxx" | "hpp" => 0xf34b7d, - "css" | "scss" | "less" => 0x563d7c, - "html" | "htm" => 0xe34c26, - "vue" => 0x41b883, - "svelte" => 0xff3e00, - "sql" => 0xe38c00, - _ => 0x4fc1ff, + "rs" => ("RS", 0xdea584), + "ts" | "tsx" => ("TS", 0x3178c6), + "js" | "jsx" | "mjs" | "cjs" => ("JS", 0xf1e05a), + "py" => ("PY", 0x3572a5), + "go" => ("GO", 0x00add8), + "java" => ("JV", 0xb07219), + "kt" => ("KT", 0xb07219), + "c" | "h" => ("C", 0x555555), + "cc" | "cpp" | "cxx" | "hpp" => ("C+", 0xf34b7d), + "cs" => ("C#", 0x178600), + "rb" => ("RB", 0x701516), + "php" => ("PH", 0x4f5d95), + "swift" => ("SW", 0xf05138), + "scala" => ("SC", 0xc22d40), + "lua" => ("LU", 0x000080), + "zig" => ("ZG", 0xec915c), + "nim" => ("NI", 0xffe953), + "ex" | "exs" => ("EX", 0x6e4a7e), + "erl" => ("ER", 0xb83998), + "hs" => ("HS", 0x5e5086), + "ml" => ("ML", 0xe37933), + "clj" => ("CL", 0x5881d8), + "dart" => ("DA", 0x00b4ab), + "vue" => ("VU", 0x41b883), + "svelte" => ("SV", 0xff3e00), + "sql" => ("SQ", 0xe38c00), + "graphql" => ("GQ", 0xe10098), + "proto" => ("PB", 0x4fc1ff), + _ => ("•", 0x4fc1ff), } } +fn file_name(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(path) + .to_ascii_lowercase() +} + +fn code_color(path: &str) -> u32 { + lang_mark(path).1 +} + #[cfg(test)] mod tests { use super::*; @@ -118,6 +190,11 @@ mod tests { assert_eq!(file_kind("setup.sh"), FileKind::Shell); assert_eq!(file_kind("logo.png"), FileKind::Image); assert_eq!(file_kind("main.rs"), FileKind::Code); + assert_eq!(file_kind("index.html"), FileKind::Html); + assert_eq!(file_kind("app.css"), FileKind::Css); + assert_eq!(file_kind("dist.zip"), FileKind::Archive); + assert_eq!(file_kind("spec.pdf"), FileKind::Pdf); + assert_eq!(file_kind("Cargo.lock"), FileKind::Lock); assert_eq!(file_kind("notes.unknown"), FileKind::Other); } @@ -125,4 +202,28 @@ mod tests { fn rust_and_typescript_use_distinct_colors() { assert_ne!(file_icon_color("lib.rs", false), file_icon_color("app.ts", false)); } + + #[test] + fn language_marks_are_distinct_for_common_code() { + assert_eq!(lang_mark("lib.rs").0, "RS"); + assert_eq!(lang_mark("app.ts").0, "TS"); + assert_eq!(lang_mark("main.py").0, "PY"); + assert_eq!(lang_mark("mod.go").0, "GO"); + assert_ne!(lang_mark("lib.rs").1, lang_mark("app.ts").1); + assert_eq!(file_kind("index.html"), FileKind::Html); + assert_eq!(file_kind("app.css"), FileKind::Css); + assert_eq!(file_kind("dist.zip"), FileKind::Archive); + } + + #[test] + fn html_css_and_archives_use_distinct_kinds() { + assert_eq!(file_kind("index.html"), FileKind::Html); + assert_eq!(file_kind("theme.scss"), FileKind::Css); + assert_eq!(file_kind("bundle.tar.gz"), FileKind::Archive); + assert_ne!( + file_icon_color("index.html", false), + file_icon_color("theme.scss", false) + ); + assert_ne!(file_icon_color("dist.zip", false), file_icon_color("main.rs", false)); + } } diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index eabbe456..fc54f35f 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -52,16 +52,22 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }) .when_some(sticky_folder(app), |el, crumb| { el.child( - div() + h_flex() .id("tree-sticky-folder") .w_full() .px_1() .py_0p5() .mb_1() + .gap_1() .rounded_md() .bg(theme.muted) .text_xs() .font_semibold() + .child( + Icon::new(IconName::FolderOpen) + .w(px(12.)) + .text_color(gpui::rgb(0xc9a227)), + ) .child(crumb), ) }) @@ -188,11 +194,7 @@ fn node_view( }); } }) - .child( - Icon::new(crate::ui::file_icons::file_icon(path, node.is_dir, node.expanded)) - .w(px(13.)) - .text_color(gpui::rgb(crate::ui::file_icons::file_icon_color(path, node.is_dir))), - ) + .child(crate::ui::file_icons::file_glyph(path, node.is_dir, node.expanded, 13.)) .child(if renaming { h_flex() .flex_1() @@ -280,15 +282,19 @@ fn node_view( fn sticky_folder(app: &AppView) -> Option { let ws = app.data.current_ws()?; - if let Some(selected) = ws.tree_selected.iter().next() { + let path = if let Some(selected) = ws.tree_selected.iter().next() { let parent = selected.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(selected); - if !parent.is_empty() { - return Some(parent.to_string()); + if parent.is_empty() { + None + } else { + Some(parent.to_string()) } - } - ws.tree - .values() - .filter(|node| node.is_dir && node.expanded && !node.path.is_empty()) - .max_by_key(|node| node.path.matches('/').count()) - .map(|node| node.path.clone()) + } else { + ws.tree + .values() + .filter(|node| node.is_dir && node.expanded && !node.path.is_empty()) + .max_by_key(|node| node.path.matches('/').count()) + .map(|node| node.path.clone()) + }?; + Some(path.rsplit('/').next().unwrap_or(path.as_str()).to_string()) } diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 9e7df18b..b7d508e5 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -4,7 +4,7 @@ use gpui::{div, img, prelude::*, px, rgb, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; use gpui_component::text::TextView; -use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; use crate::app::AppView; use crate::backend; @@ -381,9 +381,7 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> .gap_1() .pl(px(4. + 12. * depth as f32)) .child( - Icon::new(crate::ui::file_icons::file_icon(path, is_dir, false)) - .w(px(13.)) - .text_color(gpui::rgb(crate::ui::file_icons::file_icon_color(path, is_dir))), + crate::ui::file_icons::file_glyph(path, is_dir, false, 13.), ) .child(div().text_size(px(13.)).child(path.clone())) })) diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index ca0e50cd..42d965d7 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -150,11 +150,7 @@ fn file_row( } }), ) - .child( - Icon::new(crate::ui::file_icons::file_icon(path, false, false)) - .w(px(13.)) - .text_color(gpui::rgb(crate::ui::file_icons::file_icon_color(path, false))), - ) + .child(crate::ui::file_icons::file_glyph(path, false, false, 13.)) .child(div().flex_1().text_sm().child(name)) .child( div() diff --git a/src-gpui/src/ui/home.rs b/src-gpui/src/ui/home.rs index cfeb9a64..08a9c57f 100644 --- a/src-gpui/src/ui/home.rs +++ b/src-gpui/src/ui/home.rs @@ -26,7 +26,11 @@ pub fn render(app: &AppView, _window: &mut Window, cx: &mut Context) -> .gap_2() .border_b_1() .border_color(theme.border) - .child(Icon::new(IconName::Folder).text_color(theme.muted_foreground).w(px(16.))) + .child( + Icon::new(IconName::Folder) + .text_color(theme.muted_foreground) + .w(px(16.)), + ) .child(div().font_semibold().text_sm().child(app.t("home"))), ) .child( @@ -40,10 +44,24 @@ pub fn render(app: &AppView, _window: &mut Window, cx: &mut Context) -> .size(px(48.)) .rounded_full() .bg(theme.muted) + .relative() .flex() .items_center() .justify_center() - .child(Icon::new(IconName::Folder).w(px(22.))), + .child(Icon::new(IconName::Folder).w(px(22.))) + .child( + div() + .absolute() + .bottom(px(4.)) + .right(px(4.)) + .size(px(16.)) + .rounded_full() + .bg(theme.background) + .flex() + .items_center() + .justify_center() + .child(Icon::new(IconName::Plus).w(px(10.))), + ), ) .child(div().font_semibold().child(app.t("emptyProjectsTitle"))) .child( diff --git a/src-gpui/src/ui/palette.rs b/src-gpui/src/ui/palette.rs index 6f57948a..a7466f7a 100644 --- a/src-gpui/src/ui/palette.rs +++ b/src-gpui/src/ui/palette.rs @@ -1,6 +1,6 @@ use gpui::{div, prelude::*, px, Context, KeyDownEvent, Window}; use gpui_component::input::Input; -use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, StyledExt}; use crate::app::AppView; @@ -131,11 +131,7 @@ pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context }); } }) - .child( - Icon::new(crate::ui::file_icons::file_icon(&r.path, false, false)) - .w(px(16.)) - .text_color(gpui::rgb(crate::ui::file_icons::file_icon_color(&r.path, false))), - ) + .child(crate::ui::file_icons::file_glyph(&r.path, false, false, 16.)) .child(div().text_sm().child(r.name.clone())) .child( div() diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 3f9b7f94..0d2fdb2d 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -906,7 +906,7 @@ impl SettingsView { .gap_4() .child(div().font_semibold().child(self.t("topbarPreview"))) .child( - v_flex().gap_1().children(self.prefs.topbar_controls.iter().cloned().enumerate().map(|(ix, id)| { + v_flex().gap_1().children(self.prefs.topbar_controls.iter().cloned().map(|id| { let view = view.clone(); h_flex() .id(crate::ui::eid(format!("tb-order-{id}"))) @@ -950,42 +950,6 @@ impl SettingsView { "pr-status" => self.t("topbarPrStatus"), _ => id.clone(), })) - .child( - Button::new(crate::ui::eid(format!("tb-up-{id}"))) - .ghost() - .xsmall() - .icon(IconName::ChevronUp) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |this, cx| { - if ix > 0 { - this.prefs.topbar_controls.swap(ix, ix - 1); - this.persist(cx); - cx.notify(); - } - }); - } - }), - ) - .child( - Button::new(crate::ui::eid(format!("tb-dn-{id}"))) - .ghost() - .xsmall() - .icon(IconName::ChevronDown) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |this, cx| { - if ix + 1 < this.prefs.topbar_controls.len() { - this.prefs.topbar_controls.swap(ix, ix + 1); - this.persist(cx); - cx.notify(); - } - }); - } - }), - ) })), ) .when(self.prefs.topbar_controls.is_empty(), |el| { diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index 7230bab5..e8b10747 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -117,7 +117,6 @@ pub fn render( .absolute() .top(px(12.)) .right(px(12.)) - .w(px(320.)) .h(px(28.)) .px_2() .gap_1() @@ -126,7 +125,7 @@ pub fn render( .border_1() .border_color(cx.theme().border) .shadow_md() - .child(div().flex_1().child(Input::new(&app.inputs.term_search))) + .child(div().w(px(224.)).child(Input::new(&app.inputs.term_search))) .child( div() .text_xs() diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 749d228f..aee32063 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -579,7 +579,7 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { let profile = ws.profile_id.clone(); Tab::new() .label(file.title.clone()) - .icon(crate::ui::file_icons::file_icon(&file.path, false, false)) + .prefix(crate::ui::file_icons::file_glyph(&file.path, false, false, 14.)) .max_w(px(224.)) .selected(selected) .suffix( From 6f7161b4d356aa268a431d477d2514daae43e852 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 07:42:03 +0000 Subject: [PATCH 28/50] Add GPUI terminal selection copy and markdown wrap shortcuts. PTY grids now support drag/shift selection with xterm-style highlight, auto-copy on mouse up, Cmd+C copy, and Ctrl+C copy-or-SIGINT. Notes and markdown files wrap bold/italic with Cmd/Ctrl+B and I. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 69 +++++++++++++++++++++- src-gpui/src/state.rs | 110 ++++++++++++++++++++++++++++++++++++ src-gpui/src/ui/terminal.rs | 90 ++++++++++++++++++++++++----- 3 files changed, 253 insertions(+), 16 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 38957c13..a887807d 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -3,8 +3,8 @@ use std::path::Path; use std::time::Duration; use gpui::{ - div, prelude::*, px, Action, App, Context, Entity, EntityInputHandler, FocusHandle, KeyBinding, Timer, Window, - WindowHandle, + div, prelude::*, px, Action, App, ClipboardItem, Context, Entity, EntityInputHandler, FocusHandle, KeyBinding, + Timer, Window, WindowHandle, }; #[derive(Clone, PartialEq, Default, Debug, Action)] @@ -46,6 +46,12 @@ pub struct IncreaseFontSize; #[derive(Clone, PartialEq, Default, Debug, Action)] #[action(namespace = twocode, no_json)] pub struct DecreaseFontSize; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct WrapBold; +#[derive(Clone, PartialEq, Default, Debug, Action)] +#[action(namespace = twocode, no_json)] +pub struct WrapItalic; use crate::backend::{self, Backend}; use crate::i18n::{self, Locale}; use crate::prefs::{term_theme_by_name, Prefs, ThemePref}; @@ -1099,6 +1105,53 @@ impl AppView { } } + pub fn copy_term_selection(&mut self, cx: &mut Context) -> bool { + let Some(text) = self + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .map(|t| t.selected_text()) + else { + return false; + }; + if text.is_empty() { + return false; + } + cx.write_to_clipboard(ClipboardItem::new_string(text)); + true + } + + pub fn copy_term_or_interrupt(&mut self, cx: &mut Context) { + if self + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .is_some_and(|t| t.has_selection()) + { + self.copy_term_selection(cx); + } else { + self.write_to_active_pty(&[0x03]); + } + } + + pub fn wrap_active_markup(&mut self, prefix: &str, suffix: &str, window: &mut Window, cx: &mut Context) { + let markdown_file = self.data.current_ws().and_then(|w| match w.active { + Some(UnifiedTab::File { index }) => w.files.get(index).map(|f| backend::is_markdown(&f.path)), + _ => None, + }); + if markdown_file == Some(true) { + wrap_markup(&self.inputs.file_editor, prefix, suffix, window, cx); + return; + } + if self + .data + .current_ws() + .is_some_and(|w| w.sidebar_open && w.sidebar_mode == SidebarMode::Notes) + { + wrap_markup(&self.inputs.notes, prefix, suffix, window, cx); + } + } + pub fn cycle_file_search(&mut self, window: &mut Window, cx: &mut Context, next: bool) { let query = self.inputs.file_search.read(cx).value().to_string(); let draft = self.inputs.file_editor.read(cx).value().to_string(); @@ -2102,6 +2155,10 @@ impl AppView { KeyBinding::new("ctrl-=", IncreaseFontSize, None), KeyBinding::new("cmd--", DecreaseFontSize, None), KeyBinding::new("ctrl--", DecreaseFontSize, None), + KeyBinding::new("cmd-b", WrapBold, None), + KeyBinding::new("ctrl-b", WrapBold, None), + KeyBinding::new("cmd-i", WrapItalic, None), + KeyBinding::new("ctrl-i", WrapItalic, None), ]); } @@ -2538,6 +2595,14 @@ impl gpui::Render for AppView { this.bump_font_size(-1.0); cx.notify(); })) + .on_action(cx.listener(|this, _: &WrapBold, window, cx| { + this.wrap_active_markup("**", "**", window, cx); + cx.notify(); + })) + .on_action(cx.listener(|this, _: &WrapItalic, window, cx| { + this.wrap_active_markup("*", "*", window, cx); + cx.notify(); + })) .child(ui::shell::render(self, window, cx)) } } diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 8b4a6add..9be132a7 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -245,6 +245,8 @@ pub struct TermSession { pub completed_hidden: bool, pub cols: u16, pub rows: u16, + pub selection: Option<((u16, usize), (u16, usize))>, + pub selecting: bool, } impl TermSession { @@ -263,6 +265,8 @@ impl TermSession { completed_hidden: false, cols: 120, rows: 32, + selection: None, + selecting: false, } } @@ -302,6 +306,68 @@ impl TermSession { self.parser.screen().contents() } + pub fn begin_selection(&mut self, row: u16, col: usize, extend: bool) { + if extend { + if let Some((anchor, _)) = self.selection { + self.selection = Some((anchor, (row, col))); + self.selecting = true; + return; + } + } + self.selection = Some(((row, col), (row, col))); + self.selecting = true; + } + + pub fn extend_selection(&mut self, row: u16, col: usize) { + if !self.selecting { + return; + } + if let Some((anchor, _)) = self.selection { + self.selection = Some((anchor, (row, col))); + } + } + + pub fn finish_selection(&mut self) -> bool { + self.selecting = false; + !self.selected_text().is_empty() + } + + pub fn has_selection(&self) -> bool { + !self.selected_text().is_empty() + } + + pub fn cell_selected(&self, row: u16, col: usize) -> bool { + let Some(((ar, ac), (br, bc))) = self.normalized_selection() else { + return false; + }; + if row < ar || row > br { + return false; + } + if ar == br { + return col >= ac && col < bc; + } + if row == ar { + return col >= ac; + } + if row == br { + return col < bc; + } + true + } + + pub fn normalized_selection(&self) -> Option<((u16, usize), (u16, usize))> { + let (a, b) = self.selection?; + if (a.0, a.1) <= (b.0, b.1) { + Some((a, b)) + } else { + Some((b, a)) + } + } + + pub fn selected_text(&self) -> String { + selected_from_lines(&self.screen_text(), self.selection) + } + pub fn search_hits(&self, query: &str) -> Vec<(u16, usize, usize)> { if query.is_empty() { return Vec::new(); @@ -320,6 +386,34 @@ impl TermSession { } } +pub fn selected_from_lines(text: &str, sel: Option<((u16, usize), (u16, usize))>) -> String { + let Some((a, b)) = sel else { + return String::new(); + }; + let (start, end) = if (a.0, a.1) <= (b.0, b.1) { (a, b) } else { (b, a) }; + if start == end { + return String::new(); + } + let lines: Vec<&str> = text.lines().collect(); + let mut out = String::new(); + for row in start.0..=end.0 { + let line = lines.get(row as usize).copied().unwrap_or(""); + let from = if row == start.0 { start.1.min(line.len()) } else { 0 }; + let to = if row == end.0 { + end.1.min(line.len()) + } else { + line.len() + }; + if row > start.0 { + out.push('\n'); + } + if from < to { + out.extend(line.chars().skip(from).take(to - from)); + } + } + out +} + #[derive(Debug, Clone)] pub struct TreeNode { pub path: String, @@ -765,4 +859,20 @@ mod tests { assert_eq!(AgentKind::tab_icon_kind("zsh", AgentKind::Cursor), AgentKind::Cursor); assert_eq!(AgentKind::tab_icon_kind("claude", AgentKind::Codex), AgentKind::Claude); } + + #[test] + fn terminal_selection_extracts_range_and_normalizes() { + assert_eq!(selected_from_lines("hello\nworld", None), ""); + assert_eq!(selected_from_lines("hello\nworld", Some(((0, 1), (0, 4)))), "ell"); + assert_eq!(selected_from_lines("hello\nworld", Some(((0, 3), (1, 3)))), "lo\nwor"); + assert_eq!(selected_from_lines("hello\nworld", Some(((1, 3), (0, 3)))), "lo\nwor"); + assert_eq!(selected_from_lines("hello", Some(((0, 2), (0, 2)))), ""); + let mut term = TermSession::new("1".into(), "t".into(), "p".into()); + term.begin_selection(0, 1, false); + term.extend_selection(0, 4); + assert!(term.cell_selected(0, 1)); + assert!(term.cell_selected(0, 3)); + assert!(!term.cell_selected(0, 4)); + assert!(!term.cell_selected(1, 0)); + } } diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index e8b10747..2951992e 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -1,4 +1,4 @@ -use gpui::{div, prelude::*, px, rgb, Context, KeyDownEvent, Window}; +use gpui::{div, prelude::*, px, rgb, Context, KeyDownEvent, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; @@ -29,7 +29,7 @@ pub fn render( let hit_ix = term.search_ix; let id = term.id.clone(); let clickables = crate::detector::clickable_tokens(&term.screen_text()); - let grid = render_grid(term, theme, &search_query, hit_ix); + let grid = render_grid(term, theme, &search_query, hit_ix, interactive, &view); div() .id(crate::ui::eid(format!("pty-{id}"))) @@ -41,14 +41,22 @@ pub fn render( .text_size(px(app.data.prefs.font_size)) .when(!interactive, |el| el.invisible().absolute().inset_0()) .when(interactive, |el| { - el.on_mouse_up(gpui::MouseButton::Left, { + el.on_mouse_up(MouseButton::Left, { let view = view.clone(); move |_, _, cx| { view.update(cx, |app, cx| { if app.data.overlay.drag_file.is_some() { app.drop_file_on_terminal(); - cx.notify(); } + let copied = app + .data + .current_ws_mut() + .and_then(|w| w.active_terminal_mut()) + .is_some_and(|term| term.finish_selection()); + if copied { + app.copy_term_selection(cx); + } + cx.notify(); }); } }) @@ -83,6 +91,20 @@ pub fn render( }); return; } + if ev.keystroke.key == "c" + && !ev.keystroke.modifiers.alt + && !ev.keystroke.modifiers.shift + && (ev.keystroke.modifiers.platform || ev.keystroke.modifiers.control) + { + view.update(cx, |app, cx| { + if ev.keystroke.modifiers.platform { + app.copy_term_selection(cx); + } else { + app.copy_term_or_interrupt(cx); + } + }); + return; + } let handled = view.update(cx, |app, cx| { let handled = app.handle_overlay_key( ev.keystroke.key.as_str(), @@ -229,7 +251,14 @@ pub fn render( .into_any_element() } -fn render_grid(term: &crate::state::TermSession, theme: &TermTheme, query: &str, hit_ix: usize) -> impl IntoElement { +fn render_grid( + term: &crate::state::TermSession, + theme: &TermTheme, + query: &str, + hit_ix: usize, + interactive: bool, + view: &gpui::Entity, +) -> impl IntoElement { let screen = term.parser.screen(); let (rows, cols) = screen.size(); let hits = term.search_hits(query); @@ -237,7 +266,7 @@ fn render_grid(term: &crate::state::TermSession, theme: &TermTheme, query: &str, let query_len = query.len(); v_flex().id("pty-grid").children((0..rows).map(|row| { - let mut spans: Vec<(String, u32, u32, bool)> = Vec::new(); + let mut spans: Vec<(String, u32, u32, bool, bool, usize)> = Vec::new(); for col in 0..cols { let cell = screen.cell(row, col); let ch = cell @@ -262,6 +291,7 @@ fn render_grid(term: &crate::state::TermSession, theme: &TermTheme, query: &str, let highlight = hits .iter() .any(|&(r, c, len)| r == row && (col as usize) >= c && (col as usize) < c + len); + let selected = term.cell_selected(row, col as usize); if highlight { bg = if active == Some((row, col as usize, query_len)) || active.is_some_and(|(r, c, len)| r == row && (col as usize) >= c && (col as usize) < c + len) @@ -271,20 +301,52 @@ fn render_grid(term: &crate::state::TermSession, theme: &TermTheme, query: &str, 0x5f4b16 }; fg = 0xffffff; + } else if selected { + bg = 0x264f78; + fg = 0xffffff; } - if let Some((text, last_fg, last_bg, last_hi)) = spans.last_mut() { - if *last_fg == fg && *last_bg == bg && *last_hi == highlight { + if let Some((text, last_fg, last_bg, last_hi, last_sel, _)) = spans.last_mut() { + if *last_fg == fg && *last_bg == bg && *last_hi == highlight && *last_sel == selected { text.push_str(&ch); continue; } } - spans.push((ch, fg, bg, highlight)); + spans.push((ch, fg, bg, highlight, selected, col as usize)); } - h_flex().children( - spans - .into_iter() - .map(|(text, fg, bg, _)| div().bg(rgb(bg)).text_color(rgb(fg)).whitespace_nowrap().child(text)), - ) + h_flex().children(spans.into_iter().map(|(text, fg, bg, _, _, start_col)| { + let end_col = start_col + text.chars().count(); + let view = view.clone(); + let span = div().bg(rgb(bg)).text_color(rgb(fg)).whitespace_nowrap().child(text); + if !interactive { + return span.into_any_element(); + } + span.on_mouse_down(MouseButton::Left, { + let view = view.clone(); + move |ev, _, cx| { + let extend = ev.modifiers.shift; + view.update(cx, |app, cx| { + if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.begin_selection(row, start_col, extend); + } + cx.notify(); + }); + } + }) + .on_mouse_move({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + if term.selecting { + term.extend_selection(row, end_col); + cx.notify(); + } + } + }); + } + }) + .into_any_element() + })) })) } From 925bd276b247c17bb4747318e256cead738e081f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 07:45:05 +0000 Subject: [PATCH 29/50] Port leftover xterm WebLinks, progress, and clear to GPUI. URLs and file paths are underlined and clickable on the PTY grid, OSC 9;4 draws a progress bar, and Ctrl+L clears the session log plus the local screen like the old xterm addons. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 16 +++++++ src-gpui/src/backend.rs | 4 ++ src-gpui/src/detector/mod.rs | 88 ++++++++++++++++++++++++++++++---- src-gpui/src/state.rs | 14 ++++++ src-gpui/src/ui/terminal.rs | 93 ++++++++++++++++++++++++++++++++---- 5 files changed, 198 insertions(+), 17 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index a887807d..813ca3e8 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -730,6 +730,22 @@ impl AppView { } } + pub fn clear_active_terminal(&mut self) { + let Some(id) = self + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .map(|t| t.id.clone()) + else { + return; + }; + let _ = self.backend.clear_pty_output(&id); + if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.clear_screen(); + } + self.write_to_active_pty(&[0x0c]); + } + pub fn tick(&mut self, cx: &mut Context) { let started = std::time::Instant::now(); self.data.expire_toasts(); diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index a3f79863..29e6aa18 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -364,6 +364,10 @@ impl Backend { pty_infra::write_to_pty(&self.sessions, session_id, data) } + pub fn clear_pty_output(&self, session_id: &str) -> Result<(), AppError> { + service::pty::clear_output(&self.output_dir, &self.flush_senders, session_id) + } + pub fn resize_pty(&self, session_id: &str, rows: u16, cols: u16) -> Result<(), AppError> { pty_infra::resize_pty(&self.sessions, session_id, rows, cols) } diff --git a/src-gpui/src/detector/mod.rs b/src-gpui/src/detector/mod.rs index 30162e22..7ce8c580 100644 --- a/src-gpui/src/detector/mod.rs +++ b/src-gpui/src/detector/mod.rs @@ -379,17 +379,77 @@ fn regex_is_match(pat: &str, text: &str) -> bool { } pub fn clickable_tokens(screen: &str) -> Vec { + let mut out: Vec = clickable_hits(screen).into_iter().map(|h| h.token).collect(); + out.dedup(); + out.into_iter().take(8).collect() +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClickHit { + pub token: Clickable, + pub row: u16, + pub col: usize, + pub len: usize, +} + +impl ClickHit { + pub fn contains(&self, row: u16, col: usize) -> bool { + self.row == row && col >= self.col && col < self.col + self.len + } +} + +pub fn clickable_hits(screen: &str) -> Vec { let mut out = Vec::new(); - for token in screen.split_whitespace() { - let clean = token.trim_matches(|c: char| matches!(c, ',' | ';' | ')' | '(' | '[' | ']' | '"' | '\'' | '`')); - if clean.starts_with("http://") || clean.starts_with("https://") { - out.push(Clickable::Url(clean.to_string())); - } else if looks_like_path(clean) { - out.push(Clickable::Path(clean.to_string())); + for (row, line) in screen.lines().enumerate() { + let mut col = 0usize; + for part in line.split_inclusive(char::is_whitespace) { + let token = part.trim_end_matches(char::is_whitespace); + let clean = token.trim_matches(|c: char| matches!(c, ',' | ';' | ')' | '(' | '[' | ']' | '"' | '\'' | '`')); + if let Some(extra) = token.find(clean).filter(|_| !clean.is_empty()) { + let hit = if clean.starts_with("http://") || clean.starts_with("https://") { + Some(Clickable::Url(clean.to_string())) + } else if looks_like_path(clean) { + Some(Clickable::Path(clean.to_string())) + } else { + None + }; + if let Some(token) = hit { + out.push(ClickHit { + token, + row: row as u16, + col: col + extra, + len: clean.chars().count(), + }); + } + } + col += part.chars().count(); } } - out.dedup(); - out.into_iter().take(8).collect() + out +} + +/// OSC 9;4 progress used by xterm's ProgressAddon: `(state, value)`. +/// state 0 hide, 1 set, 2 error, 3 indeterminate, 4 pause. +pub fn parse_osc_progress(raw: &str) -> Option<(u8, u8)> { + if raw.is_empty() { + return None; + } + let parts: Vec<&str> = raw.split(';').collect(); + for i in 0..parts.len() { + if parts[i] == "9" && parts.get(i + 1) == Some(&"4") { + let state = parts.get(i + 2)?.parse().ok()?; + let value = parts.get(i + 3).and_then(|v| v.parse().ok()).unwrap_or(0); + return Some((state, value)); + } + } + if parts.len() >= 2 { + if let (Ok(state), Ok(value)) = (parts[0].parse::(), parts[1].parse::()) { + if state <= 4 { + return Some((state, value)); + } + } + } + None } #[derive(Clone, Debug, PartialEq, Eq)] @@ -435,5 +495,17 @@ mod tests { let tokens = clickable_tokens("see https://example.com and src/app.rs please"); assert!(tokens.contains(&Clickable::Url("https://example.com".into()))); assert!(tokens.contains(&Clickable::Path("src/app.rs".into()))); + let hits = clickable_hits("see https://example.com and src/app.rs please"); + assert_eq!(hits[0].col, 4); + assert_eq!(hits[0].len, "https://example.com".len()); + assert_eq!(hits[1].col, 4 + "https://example.com".len() + 5); + } + + #[test] + fn parses_xterm_osc_progress() { + assert_eq!(parse_osc_progress("9;4;1;40"), Some((1, 40))); + assert_eq!(parse_osc_progress("1;80"), Some((1, 80))); + assert_eq!(parse_osc_progress("0;0"), Some((0, 0))); + assert_eq!(parse_osc_progress(""), None); } } diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 9be132a7..c5548a40 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -247,6 +247,7 @@ pub struct TermSession { pub rows: u16, pub selection: Option<((u16, usize), (u16, usize))>, pub selecting: bool, + pub click_cell: Option<(u16, usize)>, } impl TermSession { @@ -267,9 +268,22 @@ impl TermSession { rows: 32, selection: None, selecting: false, + click_cell: None, } } + pub fn osc_progress(&self) -> String { + self.parser.callbacks().progress.clone() + } + + pub fn clear_screen(&mut self) { + self.parser = + vt100::Parser::new_with_callbacks(self.rows, self.cols, 10_000, crate::detector::OscSink::default()); + self.selection = None; + self.selecting = false; + self.click_cell = None; + } + pub fn feed(&mut self, bytes: &[u8]) { self.parser.process(bytes); self.detect_agent(); diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index 2951992e..dec10d50 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -1,4 +1,4 @@ -use gpui::{div, prelude::*, px, rgb, Context, KeyDownEvent, MouseButton, Window}; +use gpui::{div, prelude::*, px, relative, rgb, Context, CursorStyle, KeyDownEvent, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; @@ -29,7 +29,9 @@ pub fn render( let hit_ix = term.search_ix; let id = term.id.clone(); let clickables = crate::detector::clickable_tokens(&term.screen_text()); - let grid = render_grid(term, theme, &search_query, hit_ix, interactive, &view); + let link_hits = crate::detector::clickable_hits(&term.screen_text()); + let osc_bar = crate::detector::parse_osc_progress(&term.osc_progress()); + let grid = render_grid(term, theme, &search_query, hit_ix, interactive, &view, &link_hits); div() .id(crate::ui::eid(format!("pty-{id}"))) @@ -43,7 +45,8 @@ pub fn render( .when(interactive, |el| { el.on_mouse_up(MouseButton::Left, { let view = view.clone(); - move |_, _, cx| { + move |ev, window, cx| { + let skip = ev.modifiers.platform || ev.modifiers.control; view.update(cx, |app, cx| { if app.data.overlay.drag_file.is_some() { app.drop_file_on_terminal(); @@ -55,6 +58,27 @@ pub fn render( .is_some_and(|term| term.finish_selection()); if copied { app.copy_term_selection(cx); + } else if let Some((row, col)) = app + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .and_then(|t| t.click_cell) + { + let screen = app + .data + .current_ws() + .and_then(|w| w.active_terminal()) + .map(|t| t.screen_text()) + .unwrap_or_default(); + if let Some(hit) = crate::detector::clickable_hits(&screen) + .into_iter() + .find(|h| h.contains(row, col)) + { + app.open_clickable(&hit.token, skip, window, cx); + } + } + if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.click_cell = None; } cx.notify(); }); @@ -105,6 +129,18 @@ pub fn render( }); return; } + if ev.keystroke.key == "l" + && ev.keystroke.modifiers.control + && !ev.keystroke.modifiers.platform + && !ev.keystroke.modifiers.alt + && !ev.keystroke.modifiers.shift + { + view.update(cx, |app, cx| { + app.clear_active_terminal(); + cx.notify(); + }); + return; + } let handled = view.update(cx, |app, cx| { let handled = app.handle_overlay_key( ev.keystroke.key.as_str(), @@ -132,6 +168,32 @@ pub fn render( body.into_any_element() } }) + .when_some(osc_bar.filter(|(state, _)| *state != 0), |el, (state, value)| { + el.child( + div() + .id("pty-osc-progress") + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(3.)) + .bg(rgb(0x21262d)) + .child( + div() + .h_full() + .bg(rgb(match state { + 2 => 0xda3633, + 3 => 0x8b949e, + _ => 0x1f6feb, + })) + .w(relative(if state == 3 { + 1.0 + } else { + (value as f32 / 100.0).clamp(0.02, 1.0) + })), + ), + ) + }) .when(search_open && interactive, |el| { el.child( h_flex() @@ -258,6 +320,7 @@ fn render_grid( hit_ix: usize, interactive: bool, view: &gpui::Entity, + link_hits: &[crate::detector::ClickHit], ) -> impl IntoElement { let screen = term.parser.screen(); let (rows, cols) = screen.size(); @@ -266,7 +329,7 @@ fn render_grid( let query_len = query.len(); v_flex().id("pty-grid").children((0..rows).map(|row| { - let mut spans: Vec<(String, u32, u32, bool, bool, usize)> = Vec::new(); + let mut spans: Vec<(String, u32, u32, bool, bool, bool, usize)> = Vec::new(); for col in 0..cols { let cell = screen.cell(row, col); let ch = cell @@ -292,6 +355,7 @@ fn render_grid( .iter() .any(|&(r, c, len)| r == row && (col as usize) >= c && (col as usize) < c + len); let selected = term.cell_selected(row, col as usize); + let linked = link_hits.iter().any(|h| h.contains(row, col as usize)); if highlight { bg = if active == Some((row, col as usize, query_len)) || active.is_some_and(|(r, c, len)| r == row && (col as usize) >= c && (col as usize) < c + len) @@ -305,18 +369,28 @@ fn render_grid( bg = 0x264f78; fg = 0xffffff; } - if let Some((text, last_fg, last_bg, last_hi, last_sel, _)) = spans.last_mut() { - if *last_fg == fg && *last_bg == bg && *last_hi == highlight && *last_sel == selected { + if let Some((text, last_fg, last_bg, last_hi, last_sel, last_link, _)) = spans.last_mut() { + if *last_fg == fg + && *last_bg == bg + && *last_hi == highlight + && *last_sel == selected + && *last_link == linked + { text.push_str(&ch); continue; } } - spans.push((ch, fg, bg, highlight, selected, col as usize)); + spans.push((ch, fg, bg, highlight, selected, linked, col as usize)); } - h_flex().children(spans.into_iter().map(|(text, fg, bg, _, _, start_col)| { + h_flex().children(spans.into_iter().map(|(text, fg, bg, _, _, linked, start_col)| { let end_col = start_col + text.chars().count(); let view = view.clone(); - let span = div().bg(rgb(bg)).text_color(rgb(fg)).whitespace_nowrap().child(text); + let span = div() + .bg(rgb(bg)) + .text_color(rgb(fg)) + .whitespace_nowrap() + .when(linked, |el| el.underline().cursor(CursorStyle::PointingHand)) + .child(text); if !interactive { return span.into_any_element(); } @@ -327,6 +401,7 @@ fn render_grid( view.update(cx, |app, cx| { if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { term.begin_selection(row, start_col, extend); + term.click_cell = Some((row, start_col)); } cx.notify(); }); From b9d7163a114e419248bec8ee47a6d831546397d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 07:53:33 +0000 Subject: [PATCH 30/50] Match GPUI notes and markdown files to the leftover Milkdown layout. Replace the source/preview split with a single editor pane, a grouped icon toolbar, a vertical i18n slash menu, table and link operations, and leftover code-block language/copy chrome. Enable ligatures on terminals and editors the way xterm and Monaco did. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 184 +++++- src-gpui/src/state.rs | 16 + src-gpui/src/ui/file_viewer.rs | 196 +----- src-gpui/src/ui/markdown.rs | 1115 ++++++++++++++++++++++++++++++++ src-gpui/src/ui/mod.rs | 1 + src-gpui/src/ui/notes.rs | 161 +---- src-gpui/src/ui/terminal.rs | 4 +- 7 files changed, 1334 insertions(+), 343 deletions(-) create mode 100644 src-gpui/src/ui/markdown.rs diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 813ca3e8..001f0983 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -56,8 +56,9 @@ use crate::backend::{self, Backend}; use crate::i18n::{self, Locale}; use crate::prefs::{term_theme_by_name, Prefs, ThemePref}; use crate::state::{ - AgentStatus, AppData, ContextMenu, DialogKind, DiffPreviewMode, GitDiffTab, NotesStatus, OpenFileTab, OverlayState, - Route, SettingsTab, SidebarMode, SidebarNavItem, TermSession, ToastKind, TreeNode, UnifiedTab, Workspace, + AgentStatus, AppData, ContextMenu, DialogKind, DiffPreviewMode, GitDiffTab, MdMenu, NotesStatus, OpenFileTab, + OverlayState, Route, SettingsTab, SidebarMode, SidebarNavItem, TermSession, ToastKind, TreeNode, UnifiedTab, + Workspace, }; use crate::ui; use gpui_component::input::{Input, InputState}; @@ -89,6 +90,7 @@ pub struct Inputs { pub default_worktree: Entity, pub term_search: Entity, pub review_comment: Entity, + pub md_link: Entity, } pub struct AppView { @@ -166,6 +168,7 @@ impl AppView { default_worktree: input(window, cx, "", false), term_search: input(window, cx, "", false), review_comment: input(window, cx, "", true), + md_link: input(window, cx, "https://", false), }; inputs.project_name.update(cx, |s, cx| { @@ -585,7 +588,7 @@ impl AppView { self.inputs.file_editor.update(cx, |s, cx| { s.set_highlighter(lang, cx); s.set_line_number(show_gutter, window, cx); - s.set_soft_wrap(false, window, cx); + s.set_soft_wrap(!show_gutter, window, cx); s.set_value(content.to_string(), window, cx); }); } @@ -1150,6 +1153,167 @@ impl AppView { } } + pub fn toggle_md_menu( + &mut self, + menu: MdMenu, + target: crate::ui::markdown::MarkupTarget, + window: &mut Window, + cx: &mut Context, + ) { + if self.data.overlay.md_menu == Some(menu) { + self.data.overlay.md_menu = None; + return; + } + self.data.overlay.md_menu = Some(menu); + if menu == MdMenu::Link { + let input = crate::ui::markdown::md_input(self, target); + let text = input.read(cx).value().to_string(); + let caret = input.read(cx).cursor(); + let href = crate::ui::markdown::link_href_at(&text, caret).unwrap_or_default(); + self.inputs.md_link.update(cx, |s, cx| { + s.set_placeholder("https://", window, cx); + s.set_value(href, window, cx); + }); + } + } + + pub fn run_md( + &mut self, + target: crate::ui::markdown::MarkupTarget, + action: crate::ui::markdown::MdAction, + window: &mut Window, + cx: &mut Context, + ) { + use crate::ui::markdown::{ + apply_block_prefix, apply_link, apply_slash_at, apply_table_op, insert_snippet, remove_link, MdAction, + }; + let input = crate::ui::markdown::md_input(self, target).clone(); + match action { + MdAction::Wrap(prefix, suffix) => { + wrap_markup(&input, prefix, suffix, window, cx); + self.data.overlay.md_menu = None; + } + MdAction::Block(prefix) => { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = apply_block_prefix(&text, caret, prefix); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + MdAction::Slash(prefix, suffix) => { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = apply_slash_at(&text, caret, prefix, suffix); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + MdAction::Insert(snippet) => { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = insert_snippet(&text, caret, snippet); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + MdAction::Table(op) => { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = apply_table_op(&text, caret, op); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + MdAction::ApplyLink => { + let href = self.inputs.md_link.read(cx).value().to_string(); + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let (start, end) = match state.selected_text_range(true, window, cx) { + Some(sel) => ( + utf16_offset_to_bytes(&text, sel.range.start), + utf16_offset_to_bytes(&text, sel.range.end), + ), + None => { + let caret = state.cursor().min(text.len()); + (caret, caret) + } + }; + let (next, at) = apply_link(&text, start, end, &href); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + MdAction::RemoveLink => { + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = remove_link(&text, caret); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + self.data.overlay.md_menu = None; + } + } + if target == crate::ui::markdown::MarkupTarget::Notes { + self.data.notes_dirty_since = Some(std::time::Instant::now()); + } else { + self.data.file_dirty_since = Some(std::time::Instant::now()); + let draft = input.read(cx).value().to_string(); + if let Some(file) = self.data.current_ws_mut().and_then(|w| w.active_file_mut()) { + file.draft = draft; + } + } + } + + pub fn set_md_fence_language( + &mut self, + target: crate::ui::markdown::MarkupTarget, + lang: &str, + window: &mut Window, + cx: &mut Context, + ) { + let input = crate::ui::markdown::md_input(self, target).clone(); + input.update(cx, |state, cx| { + let text = state.value().to_string(); + let caret = state.cursor().min(text.len()); + let (next, at) = crate::ui::markdown::set_fence_language(&text, caret, lang); + state.set_value(next.clone(), window, cx); + let (line, character) = offset_line_col(&next, at); + state.set_cursor_position(gpui_component::input::Position::new(line, character), window, cx); + }); + if target == crate::ui::markdown::MarkupTarget::Notes { + self.data.notes_dirty_since = Some(std::time::Instant::now()); + } else if let Some(file) = self.data.current_ws_mut().and_then(|w| w.active_file_mut()) { + file.draft = input.read(cx).value().to_string(); + self.data.file_dirty_since = Some(std::time::Instant::now()); + } + } + + pub fn copy_md_fence(&mut self, target: crate::ui::markdown::MarkupTarget, cx: &mut Context) { + let input = crate::ui::markdown::md_input(self, target); + let text = input.read(cx).value().to_string(); + let caret = input.read(cx).cursor(); + if let Some(body) = crate::ui::markdown::fence_body(&text, caret) { + cx.write_to_clipboard(ClipboardItem::new_string(body)); + } + } + pub fn wrap_active_markup(&mut self, prefix: &str, suffix: &str, window: &mut Window, cx: &mut Context) { let markdown_file = self.data.current_ws().and_then(|w| match w.active { Some(UnifiedTab::File { index }) => w.files.get(index).map(|f| backend::is_markdown(&f.path)), @@ -2686,19 +2850,7 @@ fn utf16_offset_to_bytes(text: &str, utf16: usize) -> usize { } pub fn apply_slash_command(text: &str, prefix: &str, suffix: &str) -> String { - let mut lines: Vec<&str> = text.lines().collect(); - if let Some(last) = lines.last_mut() { - if last.starts_with('/') { - *last = ""; - } - } - let mut next = lines.join("\n"); - if !next.is_empty() && !next.ends_with('\n') { - next.push('\n'); - } - next.push_str(prefix); - next.push_str(suffix); - next + crate::ui::markdown::apply_slash_at(text, text.len(), prefix, suffix).0 } pub fn wrap_markup( diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index c5548a40..bcf62a76 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -45,6 +45,13 @@ pub enum GitDiffTab { History, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MdMenu { + Command, + Table, + Link, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum DiffPreviewMode { #[default] @@ -526,6 +533,13 @@ impl Workspace { } } + pub fn active_file(&self) -> Option<&OpenFileTab> { + match self.active { + Some(UnifiedTab::File { index }) => self.files.get(index), + _ => None, + } + } + pub fn active_file_mut(&mut self) -> Option<&mut OpenFileTab> { match self.active { Some(UnifiedTab::File { index }) => self.files.get_mut(index), @@ -588,6 +602,8 @@ pub struct OverlayState { pub group_menu_creating: bool, pub file_search_open: bool, pub file_search_ix: usize, + pub md_menu: Option, + pub md_preview: bool, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index b7d508e5..0f40543d 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -1,10 +1,11 @@ use std::path::PathBuf; +use crate::ui::markdown::{self, MarkupTarget}; use gpui::{div, img, prelude::*, px, rgb, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; use gpui_component::text::TextView; -use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable}; use crate::app::AppView; use crate::backend; @@ -45,106 +46,31 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) } if backend::is_markdown(&file.path) { - let slash = file.draft.lines().last().unwrap_or("").to_string(); + let preview = app.data.overlay.md_preview; + let draft = file.draft.clone(); return v_flex() .id("markdown-viewer") .size_full() - .child( - h_flex() - .w_full() - .px_2() - .py_1() - .gap_1() - .border_b_1() - .border_color(theme.border) - .child(md_btn("md-h1", "H1", "# ", "", &view, &app.t("notesFormatHeading1"))) - .child(md_btn("md-h2", "H2", "## ", "", &view, &app.t("notesFormatHeading2"))) - .child(md_btn("md-h3", "H3", "### ", "", &view, &app.t("notesFormatHeading3"))) - .child(md_btn("md-p", "P", "", "\n\n", &view, &app.t("notesFormatParagraph"))) - .child(md_btn("md-b", "B", "**", "**", &view, &app.t("notesFormatBold"))) - .child(md_btn("md-i", "I", "*", "*", &view, &app.t("notesFormatItalic"))) - .child(md_btn("md-s", "S", "~~", "~~", &view, &app.t("notesFormatStrike"))) - .child(md_btn("md-code", "`", "`", "`", &view, &app.t("notesFormatCode"))) - .child(md_btn( - "md-pre", - "", - "```\n", - "\n```", - &view, - &app.t("notesFormatCodeBlock"), - )) - .child(md_btn("md-ul", "•", "- ", "", &view, &app.t("notesFormatBulletList"))) - .child(md_btn( - "md-ol", - "1.", - "1. ", - "", - &view, - &app.t("notesFormatOrderedList"), - )) - .child(md_btn("md-q", ">", "> ", "", &view, &app.t("notesFormatQuote"))) - .child(md_btn("md-link", "[]", "[", "](url)", &view, &app.t("notesFormatLink"))) - .child(md_btn( - "md-img", - "img", - "![", - "](src)", - &view, - &app.t("notesFormatLink"), - )) - .child(md_btn( - "md-table", - "tbl", - "| A | B |\n| --- | --- |\n| | |\n", - "", - &view, - &app.t("notesInsertTable"), - )) - .child(md_btn("md-hr", "—", "---\n", "", &view, &app.t("notesInsertDivider"))) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(if file.dirty() { - app.t("notesSaving") - } else { - app.t("notesSaved") - }), - ) - .child( - Button::new("md-save") - .xsmall() - .primary() - .label(app.t("save")) - .on_click({ - let view = view.clone(); - move |_, window, cx| { - view.update(cx, |app, cx| { - app.save_active_file(window, cx); - cx.notify(); - }); - } - }), - ), - ) - .when(slash.starts_with('/'), |el| { - el.child(slash_menu(&slash, &view, app.data.locale)) - }) - .child( - h_flex() + .font(markdown::editor_font(app.data.prefs.font_family.clone())) + .text_size(px(app.data.prefs.font_size)) + .child(markdown::toolbar(app, MarkupTarget::File, window, cx)) + .child(if preview { + div() + .id("markdown-preview") .flex_1() .min_h_0() - .child(div().flex_1().h_full().child(Input::new(&app.inputs.file_editor))) - .child( - div() - .flex_1() - .h_full() - .p_3() - .border_l_1() - .border_color(theme.border) - .child(TextView::markdown("md-preview", file.draft.clone(), window, cx)), - ), - ) + .p_3() + .child(TextView::markdown("md-preview", draft, window, cx)) + .into_any_element() + } else { + div() + .id("markdown-editor") + .flex_1() + .min_h_0() + .p_2() + .child(Input::new(&app.inputs.file_editor)) + .into_any_element() + }) .into_any_element(); } @@ -159,7 +85,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) v_flex() .id("text-viewer") .size_full() - .font_family(app.data.prefs.font_family.clone()) + .font(markdown::editor_font(app.data.prefs.font_family.clone())) .text_size(px(app.data.prefs.font_size)) .child( h_flex() @@ -380,9 +306,7 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> h_flex() .gap_1() .pl(px(4. + 12. * depth as f32)) - .child( - crate::ui::file_icons::file_glyph(path, is_dir, false, 13.), - ) + .child(crate::ui::file_icons::file_glyph(path, is_dir, false, 13.)) .child(div().text_size(px(13.)).child(path.clone())) })) .into_any_element(); @@ -437,78 +361,6 @@ fn checkerboard() -> impl IntoElement { })) } -fn slash_menu(query: &str, view: &gpui::Entity, locale: crate::i18n::Locale) -> impl IntoElement { - let q = query.trim_start_matches('/').to_ascii_lowercase(); - let items: [(&str, &str, &str); 9] = [ - ("h1", "# ", ""), - ("h2", "## ", ""), - ("h3", "### ", ""), - ("ul", "- ", ""), - ("quote", "> ", ""), - ("code", "```\n", "\n```"), - ("link", "[", "](url)"), - ("table", "| A | B |\n| --- | --- |\n| | |\n", ""), - ("hr", "---\n", ""), - ]; - v_flex() - .id("md-slash") - .px_2() - .py_1() - .gap_1() - .w(px(180.)) - .max_h(px(280.)) - .child( - div() - .text_xs() - .text_color(gpui::hsla(0., 0., 0.5, 1.)) - .child(crate::i18n::t(locale, "notesCommandMenu")), - ) - .child( - h_flex().gap_1().flex_wrap().children( - items - .into_iter() - .filter(|(name, _, _)| q.is_empty() || name.contains(&q)) - .map(|(name, prefix, suffix)| { - let view = view.clone(); - Button::new(crate::ui::eid(format!("slash-{name}"))) - .ghost() - .xsmall() - .label(format!("/{name}")) - .on_click(move |_, window, cx| { - view.update(cx, |app, cx| { - let text = app.inputs.file_editor.read(cx).value().to_string(); - let next = crate::app::apply_slash_command(&text, prefix, suffix); - app.inputs.file_editor.update(cx, |s, cx| { - s.set_value(next, window, cx); - }); - }); - }) - }), - ), - ) -} - -fn md_btn( - id: &'static str, - label: &'static str, - prefix: &'static str, - suffix: &'static str, - view: &gpui::Entity, - tooltip: &str, -) -> impl IntoElement { - let view = view.clone(); - Button::new(id) - .ghost() - .xsmall() - .label(label) - .tooltip(tooltip.to_string()) - .on_click(move |_, window, cx| { - view.update(cx, |app, cx| { - crate::app::wrap_markup(&app.inputs.file_editor, prefix, suffix, window, cx); - }); - }) -} - #[cfg(test)] mod tests { use super::line_number_count; diff --git a/src-gpui/src/ui/markdown.rs b/src-gpui/src/ui/markdown.rs new file mode 100644 index 00000000..55e2e82e --- /dev/null +++ b/src-gpui/src/ui/markdown.rs @@ -0,0 +1,1115 @@ +use gpui::{div, prelude::*, px, Context, Entity, Window}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{Input, InputState}; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Selectable, Sizable}; + +use crate::app::AppView; +use crate::i18n; +use crate::state::{MdMenu, NotesStatus}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum MarkupTarget { + Notes, + File, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TableOp { + Insert, + AddRowAfter, + AddColAfter, + AddRowBefore, + AddColBefore, + DeleteRow, +} + +#[derive(Clone, Copy)] +pub struct SlashItem { + pub key: &'static str, + pub label_key: &'static str, + pub action: MdAction, +} + +#[derive(Clone, Copy)] +pub enum MdAction { + Wrap(&'static str, &'static str), + Block(&'static str), + Slash(&'static str, &'static str), + Insert(&'static str), + Table(TableOp), + ApplyLink, + RemoveLink, +} + +pub const DEFAULT_TABLE: &str = "| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n"; + +pub fn slash_items() -> &'static [SlashItem] { + &[ + SlashItem { + key: "paragraph", + label_key: "notesFormatParagraph", + action: MdAction::Slash("", ""), + }, + SlashItem { + key: "h1", + label_key: "notesFormatHeading1", + action: MdAction::Slash("# ", ""), + }, + SlashItem { + key: "h2", + label_key: "notesFormatHeading2", + action: MdAction::Slash("## ", ""), + }, + SlashItem { + key: "h3", + label_key: "notesFormatHeading3", + action: MdAction::Slash("### ", ""), + }, + SlashItem { + key: "ul", + label_key: "notesFormatBulletList", + action: MdAction::Slash("- ", ""), + }, + SlashItem { + key: "ol", + label_key: "notesFormatOrderedList", + action: MdAction::Slash("1. ", ""), + }, + SlashItem { + key: "quote", + label_key: "notesFormatQuote", + action: MdAction::Slash("> ", ""), + }, + SlashItem { + key: "code", + label_key: "notesFormatCodeBlock", + action: MdAction::Slash("```\n", "\n```"), + }, + SlashItem { + key: "table", + label_key: "notesInsertTable", + action: MdAction::Slash(DEFAULT_TABLE, ""), + }, + SlashItem { + key: "hr", + label_key: "notesInsertDivider", + action: MdAction::Slash("---\n", ""), + }, + ] +} + +pub fn editor_font(family: impl Into) -> gpui::Font { + let mut font = gpui::font(family); + font.features = gpui::FontFeatures(std::sync::Arc::new(vec![("liga".into(), 1), ("calt".into(), 1)])); + font +} + +pub fn line_range(text: &str, caret: usize) -> (usize, usize) { + let caret = caret.min(text.len()); + let start = text[..caret].rfind('\n').map(|i| i + 1).unwrap_or(0); + let end = text[caret..].find('\n').map(|i| caret + i).unwrap_or(text.len()); + (start, end) +} + +pub fn slash_query(text: &str, caret: usize) -> Option { + let (start, end) = line_range(text, caret); + text[start..end].strip_prefix('/').map(|rest| rest.to_string()) +} + +pub fn apply_slash_at(text: &str, caret: usize, prefix: &str, suffix: &str) -> (String, usize) { + let (start, end) = line_range(text, caret); + if text[start..end].starts_with('/') { + let mut out = String::with_capacity(text.len() + prefix.len() + suffix.len()); + out.push_str(&text[..start]); + out.push_str(prefix); + out.push_str(suffix); + out.push_str(&text[end..]); + (out, start + prefix.len()) + } else { + crate::app::wrap_markup_text(text, caret, caret, prefix, suffix) + } +} + +pub fn strip_block_prefix(line: &str) -> &str { + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix("### ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("## ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("# ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("> ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("- ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("* ") { + rest + } else if let Some(rest) = trimmed.strip_prefix("+ ") { + rest + } else if let Some((num, rest)) = trimmed.split_once(". ") { + if !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) { + rest + } else { + trimmed + } + } else { + trimmed + } +} + +pub fn apply_block_prefix(text: &str, caret: usize, prefix: &str) -> (String, usize) { + let (start, end) = line_range(text, caret); + let line = &text[start..end]; + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + let body = strip_block_prefix(line.trim_start()); + let mut out = String::with_capacity(text.len() + prefix.len()); + out.push_str(&text[..start]); + out.push_str(&indent); + out.push_str(prefix); + out.push_str(body); + out.push_str(&text[end..]); + (out, start + indent.len() + prefix.len()) +} + +pub fn insert_snippet(text: &str, caret: usize, snippet: &str) -> (String, usize) { + let (start, end) = line_range(text, caret); + let empty = text[start..end].trim().is_empty(); + let at = if empty { start } else { end }; + let mut out = String::new(); + out.push_str(&text[..at]); + if at > 0 && !text[..at].ends_with('\n') { + out.push('\n'); + } + out.push_str(snippet); + let caret = out.len(); + if empty { + out.push_str(&text[end..]); + } else { + if !snippet.ends_with('\n') && at < text.len() { + out.push('\n'); + } + out.push_str(&text[at..]); + } + let at = caret.min(out.len()); + (out, at) +} + +fn is_table_line(line: &str) -> bool { + let t = line.trim(); + t.starts_with('|') && t.ends_with('|') && t.len() > 1 +} + +fn is_separator_row(cells: &[String]) -> bool { + !cells.is_empty() + && cells.iter().all(|c| { + let t = c.trim(); + !t.is_empty() && t.chars().all(|ch| ch == '-' || ch == ':') + }) +} + +fn split_cells(line: &str) -> Vec { + let t = line.trim(); + let inner = t.strip_prefix('|').and_then(|s| s.strip_suffix('|')).unwrap_or(t); + inner.split('|').map(|c| c.trim().to_string()).collect() +} + +fn join_cells(cells: &[String]) -> String { + format!("| {} |", cells.join(" | ")) +} + +#[derive(Debug, Clone)] +struct TableSpan { + start_line: usize, + end_line: usize, + rows: Vec>, + row: usize, + col: usize, +} + +fn line_starts(text: &str) -> Vec { + let mut starts = vec![0]; + for (i, ch) in text.char_indices() { + if ch == '\n' { + starts.push(i + 1); + } + } + starts +} + +fn table_at(text: &str, caret: usize) -> Option { + let starts = line_starts(text); + let caret = caret.min(text.len()); + let line_ix = starts.iter().rposition(|&s| s <= caret).unwrap_or(0); + let lines: Vec<&str> = text.split('\n').collect(); + if line_ix >= lines.len() || !is_table_line(lines[line_ix]) { + return None; + } + let mut start = line_ix; + while start > 0 && is_table_line(lines[start - 1]) { + start -= 1; + } + let mut end = line_ix; + while end + 1 < lines.len() && is_table_line(lines[end + 1]) { + end += 1; + } + let rows: Vec> = lines[start..=end].iter().map(|l| split_cells(l)).collect(); + if rows.len() < 2 || !is_separator_row(&rows[1]) { + return None; + } + let col = { + let line = lines[line_ix]; + let local = caret.saturating_sub(starts[line_ix]); + let prefix = &line[..local.min(line.len())]; + prefix.chars().filter(|c| *c == '|').count().saturating_sub(1) + }; + Some(TableSpan { + start_line: start, + end_line: end, + row: line_ix - start, + col, + rows, + }) +} + +fn serialize_table(rows: &[Vec]) -> String { + rows.iter().map(|r| join_cells(r)).collect::>().join("\n") +} + +fn replace_lines(text: &str, start_line: usize, end_line: usize, next: &str) -> (String, usize) { + let starts = line_starts(text); + let from = starts.get(start_line).copied().unwrap_or(0); + let to = if end_line + 1 < starts.len() { + starts[end_line + 1].saturating_sub(1) + } else { + text.len() + }; + let mut out = String::new(); + out.push_str(&text[..from]); + out.push_str(next); + if to < text.len() { + if !next.ends_with('\n') && text[to..].starts_with('\n') { + out.push('\n'); + out.push_str(&text[to + 1..]); + } else { + out.push_str(&text[to..]); + } + } else if !next.ends_with('\n') && text.ends_with('\n') { + out.push('\n'); + } + (out, from) +} + +pub fn apply_table_op(text: &str, caret: usize, op: TableOp) -> (String, usize) { + if matches!(op, TableOp::Insert) { + let (start, end) = line_range(text, caret); + let empty = text[start..end].trim().is_empty(); + let insert_at = if empty { start } else { end }; + let prefix = if insert_at > 0 && !text[..insert_at].ends_with('\n') { + "\n" + } else { + "" + }; + let mut out = String::new(); + out.push_str(&text[..insert_at]); + out.push_str(prefix); + out.push_str(DEFAULT_TABLE); + if insert_at < text.len() && !DEFAULT_TABLE.ends_with('\n') { + out.push('\n'); + } + out.push_str(&text[if empty { end } else { insert_at }..]); + return (out, insert_at + prefix.len()); + } + + let Some(mut table) = table_at(text, caret) else { + return (text.to_string(), caret); + }; + let width = table.rows.first().map(|r| r.len()).unwrap_or(1).max(1); + for row in &mut table.rows { + while row.len() < width { + row.push(String::new()); + } + } + let col = table.col.min(width.saturating_sub(1)); + match op { + TableOp::Insert => {} + TableOp::AddRowAfter => { + let ix = if table.row <= 1 { + 2.min(table.rows.len()) + } else { + table.row + 1 + }; + table.rows.insert(ix.min(table.rows.len()), vec![String::new(); width]); + } + TableOp::AddRowBefore => { + let ix = if table.row <= 1 { 2 } else { table.row }; + table.rows.insert(ix.min(table.rows.len()), vec![String::new(); width]); + } + TableOp::AddColAfter => { + let at = col + 1; + for (i, row) in table.rows.iter_mut().enumerate() { + row.insert(at, if i == 1 { "---".into() } else { String::new() }); + } + } + TableOp::AddColBefore => { + for (i, row) in table.rows.iter_mut().enumerate() { + row.insert(col, if i == 1 { "---".into() } else { String::new() }); + } + } + TableOp::DeleteRow => { + if table.row <= 1 || table.rows.len() <= 3 { + return replace_lines(text, table.start_line, table.end_line, ""); + } + table.rows.remove(table.row); + } + } + if table.rows.len() >= 2 { + table.rows[1] = vec!["---".into(); table.rows[0].len().max(1)]; + } + replace_lines(text, table.start_line, table.end_line, &serialize_table(&table.rows)) +} + +fn link_span(text: &str, caret: usize) -> Option<(usize, usize, String, String)> { + let caret = caret.min(text.len()); + let window_start = caret.saturating_sub(200); + let window_end = (caret + 200).min(text.len()); + let slice = &text[window_start..window_end]; + let rel = caret - window_start; + let mut best = None; + for (i, _) in slice.match_indices('[') { + let Some(mid) = slice[i..].find("](") else { + continue; + }; + let Some(close) = slice[i + mid + 2..].find(')') else { + continue; + }; + let start = window_start + i; + let end = window_start + i + mid + 2 + close + 1; + if caret >= start && caret <= end { + let label = slice[i + 1..i + mid].to_string(); + let href = slice[i + mid + 2..i + mid + 2 + close].to_string(); + best = Some((start, end, label, href)); + if rel >= i { + break; + } + } + } + best +} + +pub fn link_href_at(text: &str, caret: usize) -> Option { + link_span(text, caret).map(|(_, _, _, href)| href) +} + +pub fn apply_link(text: &str, start: usize, end: usize, href: &str) -> (String, usize) { + let href = href.trim(); + if href.is_empty() { + return (text.to_string(), end); + } + if let Some((ls, le, label, _)) = link_span(text, start) { + let next = format!("[{label}]({href})"); + let mut out = String::new(); + out.push_str(&text[..ls]); + out.push_str(&next); + out.push_str(&text[le..]); + return (out, ls + next.len()); + } + let (a, b) = if start <= end { (start, end) } else { (end, start) }; + let label = if a == b { href } else { &text[a..b] }; + let next = format!("[{label}]({href})"); + let mut out = String::new(); + out.push_str(&text[..a]); + out.push_str(&next); + out.push_str(&text[b..]); + (out, a + next.len()) +} + +pub fn remove_link(text: &str, caret: usize) -> (String, usize) { + if let Some((start, end, label, _)) = link_span(text, caret) { + let mut out = String::new(); + out.push_str(&text[..start]); + out.push_str(&label); + out.push_str(&text[end..]); + (out, start + label.len()) + } else { + (text.to_string(), caret) + } +} + +pub const CODE_BLOCK_LANGS: &[&str] = &[ + "typescript", + "tsx", + "javascript", + "jsx", + "json", + "html", + "css", + "markdown", + "rust", + "python", + "go", + "sql", + "yaml", + "xml", + "c", + "cpp", + "shell", +]; + +pub fn fence_at(text: &str, caret: usize) -> Option<(usize, usize, String)> { + let starts = line_starts(text); + let caret = caret.min(text.len()); + let line_ix = starts.iter().rposition(|&s| s <= caret).unwrap_or(0); + let lines: Vec<&str> = text.split('\n').collect(); + let mut open = None; + for (i, line) in lines.iter().enumerate() { + if let Some(rest) = line.trim_start().strip_prefix("```") { + if open.is_none() { + open = Some((i, rest.trim().to_string())); + } else if i >= line_ix { + let (oi, lang) = open?; + if line_ix >= oi && line_ix <= i { + return Some((oi, i, lang)); + } + open = None; + } else { + open = None; + } + } + } + if let Some((oi, lang)) = open { + if line_ix >= oi { + return Some((oi, lines.len().saturating_sub(1), lang)); + } + } + None +} + +pub fn set_fence_language(text: &str, caret: usize, lang: &str) -> (String, usize) { + let Some((open_ix, _, _)) = fence_at(text, caret) else { + return (text.to_string(), caret); + }; + let starts = line_starts(text); + let from = starts[open_ix]; + let line_end = text[from..].find('\n').map(|i| from + i).unwrap_or(text.len()); + let indent = text[from..line_end] + .chars() + .take_while(|c| c.is_whitespace()) + .collect::(); + let mut out = String::new(); + out.push_str(&text[..from]); + out.push_str(&indent); + out.push_str("```"); + out.push_str(lang); + out.push_str(&text[line_end..]); + (out, from + indent.len() + 3 + lang.len()) +} + +pub fn fence_body(text: &str, caret: usize) -> Option { + let (open_ix, close_ix, _) = fence_at(text, caret)?; + let lines: Vec<&str> = text.split('\n').collect(); + if close_ix <= open_ix + 1 { + return Some(String::new()); + } + Some(lines[open_ix + 1..close_ix].join("\n")) +} + +pub fn toolbar( + app: &mut AppView, + target: MarkupTarget, + window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let menu = app.data.overlay.md_menu; + let locale = app.data.locale; + let id_prefix = match target { + MarkupTarget::Notes => "notes", + MarkupTarget::File => "md", + }; + let status = match target { + MarkupTarget::Notes => app.data.current_ws().map(|w| w.notes_status).unwrap_or_default(), + MarkupTarget::File => { + if app + .data + .current_ws() + .and_then(|w| w.active_file()) + .is_some_and(|f| f.dirty()) + { + NotesStatus::Saving + } else { + NotesStatus::Saved + } + } + }; + let badge = match status { + NotesStatus::Saving => app.t("notesSaving"), + NotesStatus::Failed => app.t("notesSaveFailedShort"), + NotesStatus::Saved => app.t("notesSaved"), + }; + let input = match target { + MarkupTarget::Notes => &app.inputs.notes, + MarkupTarget::File => &app.inputs.file_editor, + }; + let draft = input.read(cx).value().to_string(); + let caret = input.read(cx).cursor(); + let fence = fence_at(&draft, caret); + let slash = slash_query(&draft, caret); + + v_flex() + .id(crate::ui::eid(format!("{id_prefix}-md-chrome"))) + .w_full() + .child( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_1() + .flex_wrap() + .border_b_1() + .border_color(theme.border) + .child(menu_btn( + &format!("{id_prefix}-cmd"), + IconName::Menu, + &app.t("notesCommandMenu"), + menu == Some(MdMenu::Command), + MdMenu::Command, + target, + &view, + )) + .child(sep(&theme)) + .child(mark_btn( + &format!("{id_prefix}-b"), + "B", + &format!("{} ⌘B", app.t("notesFormatBold")), + MdAction::Wrap("**", "**"), + target, + &view, + )) + .child(mark_btn( + &format!("{id_prefix}-i"), + "I", + &format!("{} ⌘I", app.t("notesFormatItalic")), + MdAction::Wrap("*", "*"), + target, + &view, + )) + .child(icon_action( + &format!("{id_prefix}-code"), + IconName::Copy, + &app.t("notesFormatCode"), + MdAction::Wrap("`", "`"), + target, + &view, + )) + .child(mark_btn( + &format!("{id_prefix}-s"), + "S", + &app.t("notesFormatStrike"), + MdAction::Wrap("~~", "~~"), + target, + &view, + )) + .child(menu_btn( + &format!("{id_prefix}-link"), + IconName::ExternalLink, + &app.t("notesFormatLink"), + menu == Some(MdMenu::Link), + MdMenu::Link, + target, + &view, + )) + .child(sep(&theme)) + .child(mark_btn( + &format!("{id_prefix}-ul"), + "•", + &app.t("notesFormatBulletList"), + MdAction::Block("- "), + target, + &view, + )) + .child(mark_btn( + &format!("{id_prefix}-ol"), + "1.", + &app.t("notesFormatOrderedList"), + MdAction::Block("1. "), + target, + &view, + )) + .child(icon_action( + &format!("{id_prefix}-q"), + IconName::ChevronRight, + &app.t("notesFormatQuote"), + MdAction::Block("> "), + target, + &view, + )) + .child(sep(&theme)) + .child(menu_btn( + &format!("{id_prefix}-tbl"), + IconName::Frame, + &app.t("notesTableMenu"), + menu == Some(MdMenu::Table), + MdMenu::Table, + target, + &view, + )) + .when(target == MarkupTarget::File, |el| { + el.child( + Button::new(crate::ui::eid(format!("{id_prefix}-preview"))) + .ghost() + .xsmall() + .icon(if app.data.overlay.md_preview { + IconName::EyeOff + } else { + IconName::Eye + }) + .tooltip(if app.data.overlay.md_preview { + app.t("notesCodeBlockEdit") + } else { + app.t("notesCodeBlockHidePreview") + }) + .selected(app.data.overlay.md_preview) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.md_preview = !app.data.overlay.md_preview; + cx.notify(); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("{id_prefix}-save"))) + .xsmall() + .primary() + .label(app.t("save")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.save_active_file(window, cx); + cx.notify(); + }); + } + }), + ) + }) + .child(div().flex_1().text_xs().text_color(theme.muted_foreground).child(badge)), + ) + .when(menu == Some(MdMenu::Command), |el| { + el.child(command_menu(id_prefix, target, locale, &view)) + }) + .when(menu == Some(MdMenu::Table), |el| { + el.child(table_menu(id_prefix, target, locale, &view)) + }) + .when(menu == Some(MdMenu::Link), |el| { + el.child(link_editor(app, id_prefix, target, &view)) + }) + .when(slash.is_some(), |el| { + el.child(slash_menu(id_prefix, target, &slash.unwrap_or_default(), locale, &view)) + }) + .when(fence.is_some(), |el| { + el.child(fence_bar(app, id_prefix, target, &draft, caret, window, cx)) + }) +} + +fn sep(theme: &gpui_component::Theme) -> impl IntoElement { + div().w(px(1.)).h(px(16.)).bg(theme.border) +} + +fn menu_btn( + id: &str, + icon: IconName, + tip: &str, + selected: bool, + menu: MdMenu, + target: MarkupTarget, + view: &gpui::Entity, +) -> impl IntoElement { + let view = view.clone(); + Button::new(crate::ui::eid(id)) + .ghost() + .xsmall() + .icon(icon) + .tooltip(tip.to_string()) + .selected(selected) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.toggle_md_menu(menu, target, window, cx); + cx.notify(); + }); + }) +} + +fn mark_btn( + id: &str, + label: &str, + tip: &str, + action: MdAction, + target: MarkupTarget, + view: &gpui::Entity, +) -> impl IntoElement { + let view = view.clone(); + Button::new(crate::ui::eid(id)) + .ghost() + .xsmall() + .label(label.to_string()) + .tooltip(tip.to_string()) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, action, window, cx); + cx.notify(); + }); + }) +} + +fn icon_action( + id: &str, + icon: IconName, + tip: &str, + action: MdAction, + target: MarkupTarget, + view: &gpui::Entity, +) -> impl IntoElement { + let view = view.clone(); + Button::new(crate::ui::eid(id)) + .ghost() + .xsmall() + .icon(icon) + .tooltip(tip.to_string()) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, action, window, cx); + cx.notify(); + }); + }) +} + +fn command_menu( + id_prefix: &str, + target: MarkupTarget, + locale: crate::i18n::Locale, + view: &gpui::Entity, +) -> impl IntoElement { + let items: [(&str, &str, MdAction); 8] = [ + ("p", "notesFormatParagraph", MdAction::Block("")), + ("h1", "notesFormatHeading1", MdAction::Block("# ")), + ("h2", "notesFormatHeading2", MdAction::Block("## ")), + ("h3", "notesFormatHeading3", MdAction::Block("### ")), + ("q", "notesFormatQuote", MdAction::Block("> ")), + ("code", "notesFormatCodeBlock", MdAction::Wrap("```\n", "\n```")), + ("table", "notesInsertTable", MdAction::Table(TableOp::Insert)), + ("hr", "notesInsertDivider", MdAction::Insert("---\n")), + ]; + vertical_menu( + &format!("{id_prefix}-command-menu"), + &items.map(|(id, key, action)| (format!("{id_prefix}-cmd-{id}"), i18n::t(locale, key), action)), + target, + view, + ) +} + +fn table_menu( + id_prefix: &str, + target: MarkupTarget, + locale: crate::i18n::Locale, + view: &gpui::Entity, +) -> impl IntoElement { + let items = [ + ( + format!("{id_prefix}-tbl-insert"), + i18n::t(locale, "notesInsertTable"), + MdAction::Table(TableOp::Insert), + ), + ( + format!("{id_prefix}-tbl-row-after"), + i18n::t(locale, "notesTableAddRow"), + MdAction::Table(TableOp::AddRowAfter), + ), + ( + format!("{id_prefix}-tbl-col-after"), + i18n::t(locale, "notesTableAddColumn"), + MdAction::Table(TableOp::AddColAfter), + ), + ( + format!("{id_prefix}-tbl-row-before"), + i18n::t(locale, "notesTableAddRowBefore"), + MdAction::Table(TableOp::AddRowBefore), + ), + ( + format!("{id_prefix}-tbl-col-before"), + i18n::t(locale, "notesTableAddColumnBefore"), + MdAction::Table(TableOp::AddColBefore), + ), + ( + format!("{id_prefix}-tbl-del"), + i18n::t(locale, "notesTableDeleteCells"), + MdAction::Table(TableOp::DeleteRow), + ), + ]; + vertical_menu(&format!("{id_prefix}-table-menu"), &items, target, view) +} + +fn vertical_menu( + id: &str, + items: &[(String, String, MdAction)], + target: MarkupTarget, + view: &gpui::Entity, +) -> impl IntoElement { + v_flex() + .id(crate::ui::eid(id)) + .w(px(180.)) + .max_h(px(280.)) + .px_1() + .py_1() + .gap_1() + .border_b_1() + .border_color(gpui::hsla(0., 0., 0.5, 0.2)) + .children(items.iter().map(|(id, label, action)| { + let view = view.clone(); + let action = *action; + Button::new(crate::ui::eid(id.clone())) + .ghost() + .xsmall() + .label(label.clone()) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, action, window, cx); + cx.notify(); + }); + }) + })) +} + +fn slash_menu( + id_prefix: &str, + target: MarkupTarget, + query: &str, + locale: crate::i18n::Locale, + view: &gpui::Entity, +) -> impl IntoElement { + let q = query.to_ascii_lowercase(); + v_flex() + .id(crate::ui::eid(format!("{id_prefix}-slash"))) + .w(px(180.)) + .max_h(px(280.)) + .px_1() + .py_1() + .gap_1() + .border_b_1() + .border_color(gpui::hsla(0., 0., 0.5, 0.2)) + .child( + div() + .px_2() + .text_xs() + .text_color(gpui::hsla(0., 0., 0.5, 1.)) + .child(i18n::t(locale, "notesCommandMenu")), + ) + .children(slash_items().iter().filter_map(|item| { + let label = i18n::t(locale, item.label_key); + let hay = format!("{} {}", item.key, label.to_ascii_lowercase()); + if !q.is_empty() && !hay.contains(&q) { + return None; + } + let view = view.clone(); + let action = item.action; + Some( + Button::new(crate::ui::eid(format!("{id_prefix}-slash-{}", item.key))) + .ghost() + .xsmall() + .label(label) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, action, window, cx); + cx.notify(); + }); + }), + ) + })) +} + +fn link_editor(app: &AppView, id_prefix: &str, target: MarkupTarget, view: &gpui::Entity) -> impl IntoElement { + h_flex() + .id(crate::ui::eid(format!("{id_prefix}-link-editor"))) + .w_full() + .px_2() + .py_1() + .gap_1() + .border_b_1() + .border_color(gpui::hsla(0., 0., 0.5, 0.2)) + .child(div().flex_1().min_w(px(160.)).child(Input::new(&app.inputs.md_link))) + .child( + Button::new(crate::ui::eid(format!("{id_prefix}-link-apply"))) + .ghost() + .xsmall() + .icon(IconName::Check) + .tooltip(app.t("notesApplyLink")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, MdAction::ApplyLink, window, cx); + cx.notify(); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("{id_prefix}-link-remove"))) + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(app.t("notesRemoveLink")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.run_md(target, MdAction::RemoveLink, window, cx); + cx.notify(); + }); + } + }), + ) +} + +fn fence_bar( + app: &mut AppView, + id_prefix: &str, + target: MarkupTarget, + draft: &str, + caret: usize, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + let view = cx.entity(); + let current = fence_at(draft, caret).map(|(_, _, lang)| lang).unwrap_or_default(); + h_flex() + .id(crate::ui::eid(format!("{id_prefix}-fence"))) + .w_full() + .px_2() + .py_1() + .gap_1() + .flex_wrap() + .border_b_1() + .border_color(gpui::hsla(0., 0., 0.5, 0.2)) + .bg(gpui::rgb(0x282c34)) + .child( + div() + .text_xs() + .text_color(gpui::rgb(0xabb2bf)) + .child(if current.is_empty() { + app.t("notesCodeBlockSearchLanguage") + } else { + current.clone() + }), + ) + .children(CODE_BLOCK_LANGS.iter().map(|lang| { + let view = view.clone(); + let lang = *lang; + Button::new(crate::ui::eid(format!("{id_prefix}-lang-{lang}"))) + .ghost() + .xsmall() + .label(lang.to_string()) + .selected(lang == current) + .on_click(move |_, window, cx| { + view.update(cx, |app, cx| { + app.set_md_fence_language(target, lang, window, cx); + cx.notify(); + }); + }) + })) + .child( + Button::new(crate::ui::eid(format!("{id_prefix}-copy-fence"))) + .ghost() + .xsmall() + .icon(IconName::Copy) + .tooltip(app.t("notesCodeBlockCopy")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.copy_md_fence(target, cx); + cx.notify(); + }); + } + }), + ) +} + +pub fn md_input(app: &AppView, target: MarkupTarget) -> &Entity { + match target { + MarkupTarget::Notes => &app.inputs.notes, + MarkupTarget::File => &app.inputs.file_editor, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_block_prefix_replaces_heading() { + assert_eq!(apply_block_prefix("# hello", 3, "## "), ("## hello".into(), 3)); + assert_eq!(apply_block_prefix("- item", 2, ""), ("item".into(), 0)); + assert_eq!(apply_block_prefix("1. item", 3, "> "), ("> item".into(), 2)); + } + + #[test] + fn slash_query_reads_caret_line() { + assert_eq!(slash_query("/h1", 3).as_deref(), Some("h1")); + assert_eq!(slash_query("intro\n/tab", 10).as_deref(), Some("tab")); + assert!(slash_query("hello", 3).is_none()); + } + + #[test] + fn apply_slash_at_replaces_slash_line() { + assert_eq!(apply_slash_at("/h1", 3, "# ", ""), ("# ".into(), 2)); + assert_eq!( + apply_slash_at("intro\n/code", 10, "```\n", "\n```"), + ("intro\n```\n\n```".into(), 10) + ); + } + + #[test] + fn apply_table_op_inserts_and_edits() { + let (text, _) = apply_table_op("", 0, TableOp::Insert); + assert!(text.contains("| --- | --- | --- |")); + assert_eq!(text.lines().count(), 4); + + let src = "| A | B |\n| --- | --- |\n| x | y |"; + let (after, _) = apply_table_op(src, 30, TableOp::AddRowAfter); + assert_eq!(after.lines().count(), 4); + assert!(after.lines().last().unwrap().contains('|')); + + let (before, _) = apply_table_op(src, 30, TableOp::AddRowBefore); + assert_eq!(before.lines().count(), 4); + + let (cols, _) = apply_table_op(src, 3, TableOp::AddColAfter); + assert!(cols.lines().next().unwrap().matches('|').count() >= 4); + + let (left, _) = apply_table_op(src, 3, TableOp::AddColBefore); + assert!(left.lines().next().unwrap().starts_with("| | A")); + + let (gone, _) = apply_table_op(src, 30, TableOp::DeleteRow); + assert!(!gone.contains('x') || gone.trim().is_empty() || gone.lines().count() < 3); + } + + #[test] + fn apply_and_remove_link() { + let (linked, _) = apply_link("see here", 4, 8, "https://ex.test"); + assert_eq!(linked, "see [here](https://ex.test)"); + let (plain, _) = remove_link(&linked, 8); + assert_eq!(plain, "see here"); + } + + #[test] + fn fence_language_roundtrip() { + let src = "```\nfn main() {}\n```"; + assert_eq!(fence_at(src, 6).map(|(_, _, l)| l), Some(String::new())); + let (next, _) = set_fence_language(src, 6, "rust"); + assert_eq!(next, "```rust\nfn main() {}\n```"); + assert_eq!(fence_body(&next, 8).as_deref(), Some("fn main() {}")); + } +} diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs index 638869d2..61ff7d41 100644 --- a/src-gpui/src/ui/mod.rs +++ b/src-gpui/src/ui/mod.rs @@ -31,6 +31,7 @@ pub mod file_tree; pub mod file_viewer; pub mod git; pub mod home; +pub mod markdown; pub mod notes; pub mod palette; pub mod settings; diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs index b061bfa3..05113b59 100644 --- a/src-gpui/src/ui/notes.rs +++ b/src-gpui/src/ui/notes.rs @@ -1,24 +1,12 @@ -use gpui::{div, prelude::*, px, Context, Window}; -use gpui_component::button::{Button, ButtonVariants}; +use gpui::{div, prelude::*, Context, Window}; use gpui_component::input::Input; -use gpui_component::text::TextView; -use gpui_component::{h_flex, v_flex, ActiveTheme, Sizable, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, StyledExt}; use crate::app::AppView; -use crate::state::NotesStatus; +use crate::ui::markdown::{self, MarkupTarget}; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); - let view = cx.entity(); - let status = app.data.current_ws().map(|w| w.notes_status).unwrap_or_default(); - let badge = match status { - NotesStatus::Saving => app.t("notesSaving"), - NotesStatus::Failed => app.t("notesSaveFailedShort"), - NotesStatus::Saved => app.t("notesSaved"), - }; - let draft = app.inputs.notes.read(cx).value().to_string(); - let slash = draft.lines().last().unwrap_or("").to_string(); - v_flex() .id("notes-panel") .size_full() @@ -30,148 +18,15 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .justify_between() .border_b_1() .border_color(theme.border) - .child(div().text_xs().font_semibold().child(app.t("notes"))) - .child(div().text_xs().text_color(theme.muted_foreground).child(badge)), - ) - .child( - h_flex() - .w_full() - .px_2() - .py_1() - .gap_1() - .border_b_1() - .border_color(theme.border) - .child(note_md("n-h1", "H1", "# ", "", &view, &app.t("notesFormatHeading1"))) - .child(note_md("n-h2", "H2", "## ", "", &view, &app.t("notesFormatHeading2"))) - .child(note_md("n-h3", "H3", "### ", "", &view, &app.t("notesFormatHeading3"))) - .child(note_md("n-b", "B", "**", "**", &view, &app.t("notesFormatBold"))) - .child(note_md("n-i", "I", "*", "*", &view, &app.t("notesFormatItalic"))) - .child(note_md("n-s", "S", "~~", "~~", &view, &app.t("notesFormatStrike"))) - .child(note_md("n-code", "`", "`", "`", &view, &app.t("notesFormatCode"))) - .child(note_md( - "n-pre", - "", - "```\n", - "\n```", - &view, - &app.t("notesFormatCodeBlock"), - )) - .child(note_md("n-ul", "•", "- ", "", &view, &app.t("notesFormatBulletList"))) - .child(note_md( - "n-ol", - "1.", - "1. ", - "", - &view, - &app.t("notesFormatOrderedList"), - )) - .child(note_md("n-q", ">", "> ", "", &view, &app.t("notesFormatQuote"))) - .child(note_md("n-link", "[]", "[", "](url)", &view, &app.t("notesFormatLink"))) - .child(note_md( - "n-table", - "tbl", - "| A | B |\n| --- | --- |\n| | |\n", - "", - &view, - &app.t("notesInsertTable"), - )) - .child(note_md("n-hr", "—", "---\n", "", &view, &app.t("notesInsertDivider"))), + .child(div().text_xs().font_semibold().child(app.t("notes"))), ) - .when(slash.starts_with('/'), |el| { - el.child(notes_slash_menu(&slash, &view, app.data.locale)) - }) + .child(markdown::toolbar(app, MarkupTarget::Notes, window, cx)) .child( - v_flex() + div() .id("notes-editor") .flex_1() .min_h_0() - .child( - div() - .flex_1() - .min_h(px(120.)) - .p_2() - .child(Input::new(&app.inputs.notes)), - ) - .child( - div() - .flex_1() - .min_h(px(80.)) - .p_2() - .border_t_1() - .border_color(theme.border) - .child(TextView::markdown("notes-preview", draft, window, cx)), - ), + .p_2() + .child(Input::new(&app.inputs.notes)), ) } - -fn notes_slash_menu(query: &str, view: &gpui::Entity, locale: crate::i18n::Locale) -> impl IntoElement { - let q = query.trim_start_matches('/').to_ascii_lowercase(); - let items: [(&str, &str, &str); 9] = [ - ("h1", "# ", ""), - ("h2", "## ", ""), - ("h3", "### ", ""), - ("ul", "- ", ""), - ("quote", "> ", ""), - ("code", "```\n", "\n```"), - ("link", "[", "](url)"), - ("table", "| A | B |\n| --- | --- |\n| | |\n", ""), - ("hr", "---\n", ""), - ]; - v_flex() - .id("notes-slash") - .px_2() - .py_1() - .gap_1() - .w(px(180.)) - .max_h(px(280.)) - .child( - div() - .text_xs() - .text_color(gpui::hsla(0., 0., 0.5, 1.)) - .child(crate::i18n::t(locale, "notesCommandMenu")), - ) - .child( - h_flex().gap_1().flex_wrap().children( - items - .into_iter() - .filter(|(name, _, _)| q.is_empty() || name.contains(&q)) - .map(|(name, prefix, suffix)| { - let view = view.clone(); - Button::new(crate::ui::eid(format!("notes-slash-{name}"))) - .ghost() - .xsmall() - .label(format!("/{name}")) - .on_click(move |_, window, cx| { - view.update(cx, |app, cx| { - let text = app.inputs.notes.read(cx).value().to_string(); - let next = crate::app::apply_slash_command(&text, prefix, suffix); - app.inputs.notes.update(cx, |s, cx| { - s.set_value(next, window, cx); - }); - }); - }) - }), - ), - ) -} - -fn note_md( - id: &'static str, - label: &'static str, - prefix: &'static str, - suffix: &'static str, - view: &gpui::Entity, - tooltip: &str, -) -> impl IntoElement { - let view = view.clone(); - Button::new(id) - .ghost() - .xsmall() - .label(label) - .tooltip(tooltip.to_string()) - .on_click(move |_, window, cx| { - view.update(cx, |app, cx| { - crate::app::wrap_markup(&app.inputs.notes, prefix, suffix, window, cx); - }); - }) -} diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index dec10d50..ad4f7f5f 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -39,7 +39,7 @@ pub fn render( .relative() .bg(rgb(theme.bg)) .text_color(rgb(theme.fg)) - .font_family(app.data.prefs.font_family.clone()) + .font(crate::ui::markdown::editor_font(app.data.prefs.font_family.clone())) .text_size(px(app.data.prefs.font_size)) .when(!interactive, |el| el.invisible().absolute().inset_0()) .when(interactive, |el| { @@ -90,7 +90,7 @@ pub fn render( .id(crate::ui::eid(format!("pty-body-{id}"))) .size_full() .p_2() - .font_family(app.data.prefs.font_family.clone()) + .font(crate::ui::markdown::editor_font(app.data.prefs.font_family.clone())) .overflow_hidden() .child(grid); if interactive { From 587a0d9a86647258f24e79a539bb189cfb4abb2f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:02:27 +0000 Subject: [PATCH 31/50] Port leftover file-tree create/delete, keyboard, and iTerm images. New File/Folder now land under the right-clicked folder, Delete covers the current multi-selection, and the tree accepts arrow/Enter/Delete/F2. PTY sessions decode OSC 1337 inline images and send ^V for image clipboard pastes the way leftover xterm did. Slow UI ticks write frontend-perf.jsonl when the performance profile is on. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 300 ++++++++++++++++++++++++++++++++--- src-gpui/src/detector/mod.rs | 127 +++++++++++++++ src-gpui/src/state.rs | 24 +++ src-gpui/src/ui/dialogs.rs | 27 +++- src-gpui/src/ui/file_tree.rs | 13 +- src-gpui/src/ui/git.rs | 8 + src-gpui/src/ui/terminal.rs | 34 +++- 7 files changed, 501 insertions(+), 32 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 001f0983..2fe55d41 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -3,8 +3,8 @@ use std::path::Path; use std::time::Duration; use gpui::{ - div, prelude::*, px, Action, App, ClipboardItem, Context, Entity, EntityInputHandler, FocusHandle, KeyBinding, - Timer, Window, WindowHandle, + div, prelude::*, px, Action, App, ClipboardEntry, ClipboardItem, Context, Entity, EntityInputHandler, FocusHandle, + KeyBinding, Timer, Window, WindowHandle, }; #[derive(Clone, PartialEq, Default, Debug, Action)] @@ -794,6 +794,23 @@ impl AppView { let tick_ms = started.elapsed().as_millis(); if tick_ms >= 8 { tracing::info!(target: "perf", tick_ms, "ui tick"); + let dir = self.backend.app_data_dir.join("profiles"); + let _ = std::fs::create_dir_all(&dir); + let line = format!( + "{{\"tick_ms\":{tick_ms},\"ts\":{}}}\n", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + ); + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(dir.join("frontend-perf.jsonl")) + { + use std::io::Write; + let _ = file.write_all(line.as_bytes()); + } } } } @@ -1117,10 +1134,19 @@ impl AppView { } pub fn paste_to_pty(&mut self, cx: &mut Context) { - if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { - if !text.is_empty() { - self.write_to_active_pty(text.as_bytes()); - } + let Some(item) = cx.read_from_clipboard() else { + return; + }; + if let Some(text) = item.text().filter(|t| !t.is_empty()) { + self.write_to_active_pty(text.as_bytes()); + return; + } + if item + .entries() + .iter() + .any(|entry| matches!(entry, ClipboardEntry::Image(_))) + { + self.write_to_active_pty(&[0x16]); } } @@ -2167,53 +2193,192 @@ impl AppView { } } - pub fn create_path(&mut self, is_dir: bool, window: &mut Window, cx: &mut Context) { + pub fn create_path(&mut self, is_dir: bool, parent: Option<&str>, window: &mut Window, cx: &mut Context) { let Some(profile_id) = self.data.current_profile.clone() else { return; }; + if let Some(parent) = parent { + let loaded = self + .data + .workspaces + .get(&profile_id) + .and_then(|w| w.tree.get(parent)) + .is_some_and(|n| n.children_loaded); + if !loaded { + self.load_tree_children(&profile_id, Some(parent)); + } + } let name = self.inputs.new_path.read(cx).value().to_string(); - let existing: Vec = self + let existing = self .data .workspaces .get(&profile_id) - .map(|w| { - w.tree - .get("") - .map(|root| { - root.children - .iter() - .filter_map(|p| w.tree.get(p).map(|n| n.name.clone())) - .collect() - }) - .unwrap_or_default() - }) + .map(|w| sibling_names(&w.tree, parent)) .unwrap_or_default(); let name = if name.trim().is_empty() { unique_tree_name(&existing, if is_dir { "New Folder" } else { "New File" }) } else { name }; - if let Err(err) = self.backend.create_path(&profile_id, &name, is_dir) { + let path = join_tree_path(parent, &name); + if let Err(err) = self.backend.create_path(&profile_id, &path, is_dir) { self.data .push_toast(ToastKind::Error, self.t("fileTreeCreateErrorTitle"), err.to_string()); return; } - self.load_tree_root(&profile_id); - self.start_rename_path(&name, window, cx); + if let Some(parent) = parent { + self.load_tree_children(&profile_id, Some(parent)); + } else { + self.load_tree_root(&profile_id); + } + self.start_rename_path(&path, window, cx); } - pub fn delete_tree_path(&mut self, path: &str) { + pub fn delete_tree_paths(&mut self, paths: &[String]) { let Some(profile_id) = self.data.current_profile.clone() else { return; }; - if let Err(err) = self.backend.delete_paths(&profile_id, &[path.to_string()]) { + if paths.is_empty() { + return; + } + if let Err(err) = self.backend.delete_paths(&profile_id, paths) { self.data .push_toast(ToastKind::Error, self.t("fileTreeDeleteErrorTitle"), err.to_string()); return; } + if let Some(ws) = self.data.workspaces.get_mut(&profile_id) { + ws.files.retain(|f| !paths.iter().any(|p| p == &f.path)); + if let Some(UnifiedTab::File { index }) = ws.active { + if index >= ws.files.len() { + ws.active = ws.files.last().map(|_| UnifiedTab::File { + index: ws.files.len() - 1, + }); + } + } + ws.tree_selected.retain(|p| !paths.iter().any(|gone| gone == p)); + } self.load_tree_root(&profile_id); } + pub fn tree_key(&mut self, key: &str, shift: bool, window: &mut Window, cx: &mut Context) -> bool { + if self.data.overlay.renaming_path.is_some() + || self.data.overlay.palette_open + || self.data.overlay.dialog.is_some() + || self.data.overlay.context_menu.is_some() + { + return false; + } + let Some(profile) = self.data.current_profile.clone() else { + return false; + }; + let Some(ws) = self.data.workspaces.get(&profile) else { + return false; + }; + if ws.sidebar_mode != SidebarMode::Files { + return false; + } + let paths = visible_tree_paths(ws); + if paths.is_empty() { + return false; + } + let current = ws + .tree_anchor + .clone() + .or_else(|| ws.tree_selected.iter().next().cloned()) + .unwrap_or_else(|| paths[0].clone()); + let ix = paths.iter().position(|p| p == ¤t).unwrap_or(0); + let is_dir = ws.tree.get(¤t).is_some_and(|n| n.is_dir); + let expanded = ws.tree.get(¤t).is_some_and(|n| n.expanded); + let parent = create_target_directory(&ws.tree, Some(¤t)); + let next_child = paths + .get(ix + 1) + .filter(|p| p.starts_with(&format!("{current}/"))) + .cloned(); + match key { + "up" | "down" => { + let next = if key == "up" { + ix.saturating_sub(1) + } else { + (ix + 1).min(paths.len() - 1) + }; + self.select_tree_path(&paths[next], shift); + true + } + "left" => { + if is_dir && expanded { + self.toggle_dir(&profile, ¤t); + } else if let Some(parent) = parent { + if parent != current { + self.select_tree_path(&parent, false); + } + } + true + } + "right" => { + if is_dir && !expanded { + self.toggle_dir(&profile, ¤t); + } else if let Some(child) = next_child { + self.select_tree_path(&child, false); + } + true + } + "enter" => { + let is_dir = self + .data + .workspaces + .get(&profile) + .and_then(|w| w.tree.get(¤t)) + .is_some_and(|n| n.is_dir); + if is_dir { + self.toggle_dir(&profile, ¤t); + } else if !self.is_deleted_tree_path(&profile, ¤t) { + self.open_file(&profile, ¤t, window, cx); + } + true + } + "delete" | "backspace" => { + let selected = self + .data + .workspaces + .get(&profile) + .map(|w| context_action_paths(&w.tree_selected, ¤t)) + .unwrap_or_else(|| vec![current]); + self.delete_tree_paths(&selected); + true + } + "f2" => { + self.start_rename_path(¤t, window, cx); + true + } + _ => false, + } + } + + fn select_tree_path(&mut self, path: &str, range: bool) { + let Some(profile) = self.data.current_profile.clone() else { + return; + }; + let Some(ws) = self.data.workspaces.get_mut(&profile) else { + return; + }; + if range { + let paths = visible_tree_paths(ws); + let anchor = ws.tree_anchor.clone().unwrap_or_else(|| path.to_string()); + if let (Some(a), Some(b)) = ( + paths.iter().position(|p| p == &anchor), + paths.iter().position(|p| p == path), + ) { + let (lo, hi) = if a <= b { (a, b) } else { (b, a) }; + ws.tree_selected = paths[lo..=hi].iter().cloned().collect(); + ws.tree_anchor = Some(anchor); + return; + } + } + ws.tree_selected.clear(); + ws.tree_selected.insert(path.to_string()); + ws.tree_anchor = Some(path.to_string()); + } + pub fn reveal(&mut self, path: Option<&str>) { let Some(profile_id) = self.data.current_profile.clone() else { return; @@ -2557,6 +2722,46 @@ impl AppView { } } +pub fn create_target_directory(tree: &HashMap, context: Option<&str>) -> Option { + let path = context?.trim_end_matches('/'); + if path.is_empty() { + return None; + } + if tree.get(path).is_some_and(|node| node.is_dir) { + return Some(path.to_string()); + } + path.rfind('/').map(|i| path[..i].to_string()).filter(|p| !p.is_empty()) +} + +pub fn join_tree_path(parent: Option<&str>, name: &str) -> String { + match parent.map(str::trim).filter(|p| !p.is_empty()) { + Some(parent) => format!("{parent}/{name}"), + None => name.to_string(), + } +} + +pub fn sibling_names(tree: &HashMap, parent: Option<&str>) -> Vec { + let key = parent.unwrap_or(""); + tree.get(key) + .map(|node| { + node.children + .iter() + .filter_map(|child| tree.get(child).map(|n| n.name.clone())) + .collect() + }) + .unwrap_or_default() +} + +pub fn context_action_paths(selected: &HashSet, clicked: &str) -> Vec { + if selected.contains(clicked) { + let mut paths: Vec = selected.iter().cloned().collect(); + paths.sort(); + paths + } else { + vec![clicked.to_string()] + } +} + pub fn unique_tree_name(existing: &[String], base: &str) -> String { if !existing.iter().any(|n| n == base) { return base.to_string(); @@ -2882,9 +3087,12 @@ pub fn wrap_markup( #[cfg(test)] mod tests { use super::{ - apply_slash_command, file_status_badge, git_status_kind, offset_line_col, search_match_offsets, - suggested_project_name, unique_tree_name, wrap_markup_text, GitStatusKind, + apply_slash_command, context_action_paths, create_target_directory, file_status_badge, git_status_kind, + join_tree_path, offset_line_col, search_match_offsets, sibling_names, suggested_project_name, unique_tree_name, + wrap_markup_text, GitStatusKind, }; + use crate::state::TreeNode; + use std::collections::{HashMap, HashSet}; #[test] fn unique_tree_name_adds_numbers() { @@ -2896,6 +3104,46 @@ mod tests { ); } + fn node(path: &str, is_dir: bool, children: Vec) -> TreeNode { + TreeNode { + path: path.into(), + name: path.rsplit('/').next().unwrap_or(path).into(), + is_dir, + expanded: is_dir, + children_loaded: true, + children, + } + } + + #[test] + fn create_target_directory_uses_folder_or_parent() { + let mut tree = HashMap::new(); + tree.insert("".into(), node("", true, vec!["src".into(), "README.md".into()])); + tree.insert("src".into(), node("src", true, vec!["src/main.rs".into()])); + tree.insert("src/main.rs".into(), node("src/main.rs", false, Vec::new())); + tree.insert("README.md".into(), node("README.md", false, Vec::new())); + assert_eq!(create_target_directory(&tree, Some("src")).as_deref(), Some("src")); + assert_eq!( + create_target_directory(&tree, Some("src/main.rs")).as_deref(), + Some("src") + ); + assert_eq!(create_target_directory(&tree, Some("README.md")), None); + assert_eq!(create_target_directory(&tree, None), None); + assert_eq!(join_tree_path(Some("src"), "New File"), "src/New File"); + assert_eq!(join_tree_path(None, "New File"), "New File"); + assert_eq!(sibling_names(&tree, Some("src")), vec!["main.rs".to_string()]); + } + + #[test] + fn context_action_paths_uses_selection_when_clicked_is_selected() { + let selected = HashSet::from(["a".into(), "b".into()]); + assert_eq!( + context_action_paths(&selected, "a"), + vec!["a".to_string(), "b".to_string()] + ); + assert_eq!(context_action_paths(&selected, "c"), vec!["c".to_string()]); + } + #[test] fn suggested_project_name_uses_basename_when_empty() { assert_eq!(suggested_project_name("/tmp/my-app", ""), "my-app"); diff --git a/src-gpui/src/detector/mod.rs b/src-gpui/src/detector/mod.rs index 7ce8c580..57487dfa 100644 --- a/src-gpui/src/detector/mod.rs +++ b/src-gpui/src/detector/mod.rs @@ -452,6 +452,115 @@ pub fn parse_osc_progress(raw: &str) -> Option<(u8, u8)> { None } +/// iTerm2 inline images (`OSC 1337 ; File=…`). Leftover xterm ImageAddon. +pub fn extract_iterm2_images(carry: &mut Vec, incoming: &[u8]) -> Vec> { + carry.extend_from_slice(incoming); + if carry.len() > 4 * 1024 * 1024 { + carry.drain(..carry.len() - 64); + } + let mut images = Vec::new(); + loop { + let Some(start) = carry.windows(2).position(|w| w == [0x1b, b']']) else { + if !carry.is_empty() && carry[carry.len() - 1] != 0x1b { + carry.clear(); + } else if carry.len() > 1 { + carry.drain(..carry.len() - 1); + } + break; + }; + if start > 0 { + carry.drain(..start); + } + let Some(end) = osc_terminator(carry) else { + break; + }; + let seq: Vec = carry.drain(..end).collect(); + if let Some(img) = parse_iterm2_osc(&seq) { + images.push(img); + } + } + images +} + +fn osc_terminator(buf: &[u8]) -> Option { + for i in 2..buf.len() { + if buf[i] == 0x07 { + return Some(i + 1); + } + if buf[i] == b'\\' && buf[i - 1] == 0x1b { + return Some(i + 1); + } + } + None +} + +pub fn parse_iterm2_file(payload: &str) -> Option> { + let body = payload.strip_prefix("1337;").unwrap_or(payload); + let rest = body.strip_prefix("File=")?; + let (meta, data) = rest.rsplit_once(':')?; + if meta.split(';').any(|part| part.eq_ignore_ascii_case("inline=0")) { + return None; + } + decode_base64(data) +} + +fn parse_iterm2_osc(seq: &[u8]) -> Option> { + let inner = seq.strip_prefix(&[0x1b, b']'])?; + let inner = inner + .strip_suffix(&[0x07]) + .or_else(|| inner.strip_suffix(&[0x1b, b'\\']))?; + parse_iterm2_file(&String::from_utf8_lossy(inner)) +} + +fn decode_base64(input: &str) -> Option> { + fn val(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let bytes: Vec = input + .bytes() + .filter(|b| !b.is_ascii_whitespace() && *b != b'=') + .collect(); + if bytes.is_empty() { + return None; + } + let mut out = Vec::with_capacity(bytes.len() * 3 / 4 + 1); + for chunk in bytes.chunks(4) { + let a = val(chunk[0])?; + let b = val(*chunk.get(1)?)?; + out.push((a << 2) | (b >> 4)); + if chunk.len() >= 3 { + let c = val(chunk[2])?; + out.push((b << 4) | (c >> 2)); + if chunk.len() == 4 { + let d = val(chunk[3])?; + out.push((c << 6) | d); + } + } + } + Some(out) +} + +pub fn image_format(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(&[0x89, b'P', b'N', b'G']) { + Some("png") + } else if bytes.starts_with(&[0xFF, 0xD8]) { + Some("jpeg") + } else if bytes.starts_with(b"GIF8") { + Some("gif") + } else if bytes.len() > 12 && &bytes[8..12] == b"WEBP" { + Some("webp") + } else { + None + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum Clickable { Url(String), @@ -508,4 +617,22 @@ mod tests { assert_eq!(parse_osc_progress("0;0"), Some((0, 0))); assert_eq!(parse_osc_progress(""), None); } + + #[test] + fn parses_iterm2_inline_png() { + const PNG: &str = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + let decoded = parse_iterm2_file(&format!("1337;File=inline=1;name=dot:{PNG}")).unwrap(); + assert_eq!(image_format(&decoded), Some("png")); + assert!(parse_iterm2_file(&format!("1337;File=inline=0:{PNG}")).is_none()); + + let mut carry = Vec::new(); + let mut seq = vec![0x1b, b']']; + seq.extend(format!("1337;File=inline=1:{PNG}").bytes()); + seq.push(0x07); + let imgs = extract_iterm2_images(&mut carry, &seq); + assert_eq!(imgs.len(), 1); + assert_eq!(image_format(&imgs[0]), Some("png")); + assert!(carry.is_empty()); + } } diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index bcf62a76..43a2a903 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -255,6 +255,15 @@ pub struct TermSession { pub selection: Option<((u16, usize), (u16, usize))>, pub selecting: bool, pub click_cell: Option<(u16, usize)>, + pub osc_carry: Vec, + pub images: Vec, +} + +#[derive(Clone)] +pub struct TermImage { + pub row: u16, + pub col: u16, + pub bytes: Vec, } impl TermSession { @@ -276,6 +285,8 @@ impl TermSession { selection: None, selecting: false, click_cell: None, + osc_carry: Vec::new(), + images: Vec::new(), } } @@ -289,10 +300,23 @@ impl TermSession { self.selection = None; self.selecting = false; self.click_cell = None; + self.osc_carry.clear(); + self.images.clear(); } pub fn feed(&mut self, bytes: &[u8]) { + let images = crate::detector::extract_iterm2_images(&mut self.osc_carry, bytes); self.parser.process(bytes); + if !images.is_empty() { + let (row, col) = self.parser.screen().cursor_position(); + for data in images { + self.images.push(TermImage { row, col, bytes: data }); + } + if self.images.len() > 16 { + let extra = self.images.len() - 16; + self.images.drain(0..extra); + } + } self.detect_agent(); } diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index 56c80431..d5867180 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -1095,13 +1095,34 @@ fn run_menu(app: &mut AppView, menu: &ContextMenu, action: MenuAction, window: & (ContextMenu::File { path }, MenuAction::RenamePath) => { app.start_rename_path(path, window, cx); } - (_, MenuAction::NewFile) => app.create_path(false, window, cx), - (_, MenuAction::NewFolder) => app.create_path(true, window, cx), + (ContextMenu::File { path }, MenuAction::NewFile) => { + let parent = app + .data + .current_ws() + .and_then(|w| crate::app::create_target_directory(&w.tree, Some(path))); + app.create_path(false, parent.as_deref(), window, cx) + } + (ContextMenu::File { path }, MenuAction::NewFolder) => { + let parent = app + .data + .current_ws() + .and_then(|w| crate::app::create_target_directory(&w.tree, Some(path))); + app.create_path(true, parent.as_deref(), window, cx) + } + (_, MenuAction::NewFile) => app.create_path(false, None, window, cx), + (_, MenuAction::NewFolder) => app.create_path(true, None, window, cx), (ContextMenu::File { path }, MenuAction::CopyRel) => app.copy_path(path, false, cx), (ContextMenu::File { path }, MenuAction::CopyAbs) => app.copy_path(path, true, cx), (ContextMenu::TreeBlank, MenuAction::CopyRel) => app.copy_path(".", false, cx), (ContextMenu::TreeBlank, MenuAction::CopyAbs) => app.copy_path(".", true, cx), - (ContextMenu::File { path }, MenuAction::DeletePath) => app.delete_tree_path(path), + (ContextMenu::File { path }, MenuAction::DeletePath) => { + let paths = app + .data + .current_ws() + .map(|w| crate::app::context_action_paths(&w.tree_selected, path)) + .unwrap_or_else(|| vec![path.clone()]); + app.delete_tree_paths(&paths); + } (_, MenuAction::NewTerm) => app.create_terminal(&app.t("newTerminal"), "", Vec::new()), (_, MenuAction::Template(i)) => { if let Some(t) = app.data.prefs.templates.get(i).cloned() { diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index fc54f35f..c059fe0c 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -25,6 +25,17 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .px_1() .py_1() .text_size(px(13.)) + .tab_index(0) + .on_key_down({ + let view = view.clone(); + move |ev: &gpui::KeyDownEvent, window, cx| { + view.update(cx, |app, cx| { + if app.tree_key(ev.keystroke.key.as_str(), ev.keystroke.modifiers.shift, window, cx) { + cx.notify(); + } + }); + } + }) .on_mouse_down(MouseButton::Right, { let view = view.clone(); move |ev, _, cx| { @@ -296,5 +307,5 @@ fn sticky_folder(app: &AppView) -> Option { .max_by_key(|node| node.path.matches('/').count()) .map(|node| node.path.clone()) }?; - Some(path.rsplit('/').next().unwrap_or(path.as_str()).to_string()) + Some(path) } diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index 42d965d7..90035148 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -208,7 +208,9 @@ fn commit_composer( .font_semibold() .child(app.t("gitCommitSectionTitle").to_ascii_uppercase()), ) + .child(div().text_xs().child(app.t("gitCommitSummary"))) .child(Input::new(&app.inputs.commit_summary)) + .child(div().text_xs().child(app.t("gitCommitBody"))) .child(Input::new(&app.inputs.commit_body)) .child( h_flex() @@ -386,6 +388,12 @@ pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Cont .child( h_flex() .gap_2() + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("gitDiffPreviewMode")), + ) .child( Button::new("unified") .xsmall() diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index ad4f7f5f..f6f1b40e 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -1,4 +1,7 @@ -use gpui::{div, prelude::*, px, relative, rgb, Context, CursorStyle, KeyDownEvent, MouseButton, Window}; +use gpui::{ + div, img, prelude::*, px, relative, rgb, Context, CursorStyle, Image, ImageFormat, KeyDownEvent, MouseButton, + Window, +}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; @@ -31,6 +34,8 @@ pub fn render( let clickables = crate::detector::clickable_tokens(&term.screen_text()); let link_hits = crate::detector::clickable_hits(&term.screen_text()); let osc_bar = crate::detector::parse_osc_progress(&term.osc_progress()); + let images = term.images.clone(); + let font_size = app.data.prefs.font_size; let grid = render_grid(term, theme, &search_query, hit_ix, interactive, &view, &link_hits); div() @@ -92,7 +97,32 @@ pub fn render( .p_2() .font(crate::ui::markdown::editor_font(app.data.prefs.font_family.clone())) .overflow_hidden() - .child(grid); + .relative() + .child(grid) + .children(images.into_iter().enumerate().filter_map(|(i, image)| { + let kind = crate::detector::image_format(&image.bytes)?; + let format = match kind { + "jpeg" => ImageFormat::Jpeg, + "gif" => ImageFormat::Gif, + "webp" => ImageFormat::Webp, + _ => ImageFormat::Png, + }; + let top = 8.0 + image.row as f32 * font_size * 1.2; + let left = 8.0 + image.col as f32 * font_size * 0.62; + Some( + div() + .id(crate::ui::eid(format!("pty-img-{id}-{i}"))) + .absolute() + .top(px(top)) + .left(px(left)) + .child( + img(std::sync::Arc::new(Image::from_bytes(format, image.bytes))) + .id(crate::ui::eid(format!("pty-img-src-{id}-{i}"))) + .max_w(px(480.)) + .max_h(px(280.)), + ), + ) + })); if interactive { body.on_key_down({ let view = view.clone(); From 7827b389e9324e1fec688023bf15d21a48b629d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:10:02 +0000 Subject: [PATCH 32/50] Measure GPUI terminal cells from the font and render wide glyphs. PTY columns now come from the advance of "W" (xterm's metric) instead of a 0.62em guess, so CJK/emoji no longer leave empty columns. Continuation cells are skipped and wide glyphs span two columns. Sidebar resize and completed-dot tooltips match the leftover hardcoded labels. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 23 ++-- src-gpui/src/ui/mod.rs | 7 +- src-gpui/src/ui/sidebar.rs | 3 + src-gpui/src/ui/terminal.rs | 254 +++++++++++++++++++++++++++-------- src-gpui/src/ui/workspace.rs | 2 + 5 files changed, 218 insertions(+), 71 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 2fe55d41..c887d4a7 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -1420,17 +1420,18 @@ impl AppView { fn estimate_pty_size(&self, window: &Window) -> (u16, u16) { let size = window.viewport_size(); - let mut chrome = 48.0; - if !self.data.prefs.sidebar_collapsed { - chrome += self.data.prefs.sidebar_width; - } - if self.data.current_ws().map(|w| w.sidebar_open).unwrap_or(false) { - chrome += self.data.prefs.profile_sidebar_width; - } - let font = self.data.prefs.font_size.max(10.0); - let cols = ((f32::from(size.width) - chrome - 24.0) / (font * 0.62)).floor() as i32; - let rows = ((f32::from(size.height) - 96.0) / (font * 1.35)).floor() as i32; - (rows.clamp(10, 120) as u16, cols.clamp(40, 300) as u16) + let (cell_w, cell_h) = + ui::terminal::measure_pty_cell(window, self.data.prefs.font_family.clone(), self.data.prefs.font_size); + ui::terminal::pty_grid_size( + f32::from(size.width), + f32::from(size.height), + cell_w, + cell_h, + self.data.prefs.sidebar_collapsed, + self.data.prefs.sidebar_width, + self.data.current_ws().map(|w| w.sidebar_open).unwrap_or(false), + self.data.prefs.profile_sidebar_width, + ) } pub fn apply_update_result(&mut self, result: Result, silent: bool) { diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs index 61ff7d41..98abb976 100644 --- a/src-gpui/src/ui/mod.rs +++ b/src-gpui/src/ui/mod.rs @@ -1,9 +1,14 @@ -use gpui::{div, prelude::*, Context, SharedString, Window}; +use gpui::{div, prelude::*, AnyView, App, Context, SharedString, Window}; pub fn eid(s: impl Into) -> SharedString { SharedString::from(s.into()) } +pub fn tip(text: impl Into) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static { + let text = text.into(); + move |window, cx| gpui_component::tooltip::Tooltip::new(text.clone()).build(window, cx) +} + pub struct DragGhost { pub label: String, } diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index 9ae62527..6ef4cf68 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -165,6 +165,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) view, false, app.data.overlay.sidebar_resize_focus == Some(false), + "Resize sidebar".to_string(), )) .into_any_element() } @@ -196,6 +197,7 @@ pub(crate) fn resize_handle( view: gpui::Entity, profile: bool, focused: bool, + label: String, ) -> impl IntoElement { div() .id(id) @@ -206,6 +208,7 @@ pub(crate) fn resize_handle( .h_full() .tab_index(0) .cursor(gpui::CursorStyle::ResizeColumn) + .tooltip(crate::ui::tip(label)) .when(focused, |el| el.bg(gpui::hsla(0., 0., 0.5, 0.3))) .on_mouse_down(MouseButton::Left, { let view = view.clone(); diff --git a/src-gpui/src/ui/terminal.rs b/src-gpui/src/ui/terminal.rs index f6f1b40e..15d9d84f 100644 --- a/src-gpui/src/ui/terminal.rs +++ b/src-gpui/src/ui/terminal.rs @@ -1,6 +1,6 @@ use gpui::{ div, img, prelude::*, px, relative, rgb, Context, CursorStyle, Image, ImageFormat, KeyDownEvent, MouseButton, - Window, + TextRun, Window, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; @@ -14,7 +14,7 @@ pub fn render( profile_id: &str, index: usize, interactive: bool, - _window: &mut Window, + window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let theme = app.current_term_theme(); @@ -36,7 +36,18 @@ pub fn render( let osc_bar = crate::detector::parse_osc_progress(&term.osc_progress()); let images = term.images.clone(); let font_size = app.data.prefs.font_size; - let grid = render_grid(term, theme, &search_query, hit_ix, interactive, &view, &link_hits); + let (cell_w, cell_h) = measure_pty_cell(window, app.data.prefs.font_family.clone(), font_size); + let grid = render_grid( + term, + theme, + &search_query, + hit_ix, + interactive, + &view, + &link_hits, + cell_w, + cell_h, + ); div() .id(crate::ui::eid(format!("pty-{id}"))) @@ -107,8 +118,8 @@ pub fn render( "webp" => ImageFormat::Webp, _ => ImageFormat::Png, }; - let top = 8.0 + image.row as f32 * font_size * 1.2; - let left = 8.0 + image.col as f32 * font_size * 0.62; + let top = 8.0 + image.row as f32 * cell_h; + let left = 8.0 + image.col as f32 * cell_w; Some( div() .id(crate::ui::eid(format!("pty-img-{id}-{i}"))) @@ -351,6 +362,8 @@ fn render_grid( interactive: bool, view: &gpui::Entity, link_hits: &[crate::detector::ClickHit], + cell_w: f32, + cell_h: f32, ) -> impl IntoElement { let screen = term.parser.screen(); let (rows, cols) = screen.size(); @@ -359,19 +372,20 @@ fn render_grid( let query_len = query.len(); v_flex().id("pty-grid").children((0..rows).map(|row| { - let mut spans: Vec<(String, u32, u32, bool, bool, bool, usize)> = Vec::new(); + let mut cells = Vec::with_capacity(cols as usize); for col in 0..cols { let cell = screen.cell(row, col); - let ch = cell + let (ch, wide, continuation) = cell .map(|c| { let text = c.contents(); - if text.is_empty() { + let ch = if text.is_empty() { " ".to_string() } else { text.to_string() - } + }; + (ch, c.is_wide(), c.is_wide_continuation()) }) - .unwrap_or_else(|| " ".into()); + .unwrap_or_else(|| (" ".into(), false, false)); let (mut fg, mut bg) = cell .map(|c| (map_color(c.fgcolor(), theme.fg), map_color(c.bgcolor(), theme.bg))) .unwrap_or((theme.fg, theme.bg)); @@ -399,62 +413,135 @@ fn render_grid( bg = 0x264f78; fg = 0xffffff; } - if let Some((text, last_fg, last_bg, last_hi, last_sel, last_link, _)) = spans.last_mut() { - if *last_fg == fg - && *last_bg == bg - && *last_hi == highlight - && *last_sel == selected - && *last_link == linked - { - text.push_str(&ch); - continue; - } - } - spans.push((ch, fg, bg, highlight, selected, linked, col as usize)); + cells.push((ch, wide, continuation, (fg, bg, highlight, selected, linked))); } - h_flex().children(spans.into_iter().map(|(text, fg, bg, _, _, linked, start_col)| { - let end_col = start_col + text.chars().count(); - let view = view.clone(); - let span = div() - .bg(rgb(bg)) - .text_color(rgb(fg)) - .whitespace_nowrap() - .when(linked, |el| el.underline().cursor(CursorStyle::PointingHand)) - .child(text); - if !interactive { - return span.into_any_element(); - } - span.on_mouse_down(MouseButton::Left, { + let spans = merge_row_spans(&cells); + h_flex() + .h(px(cell_h)) + .children(spans.into_iter().map(|(text, span_cols, start_col, style)| { + let (fg, bg, _, _, linked) = style; + let end_col = start_col + span_cols as usize; let view = view.clone(); - move |ev, _, cx| { - let extend = ev.modifiers.shift; - view.update(cx, |app, cx| { - if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { - term.begin_selection(row, start_col, extend); - term.click_cell = Some((row, start_col)); - } - cx.notify(); - }); + let span = div() + .w(px(cell_w * span_cols as f32)) + .min_w(px(cell_w * span_cols as f32)) + .h(px(cell_h)) + .overflow_hidden() + .bg(rgb(bg)) + .text_color(rgb(fg)) + .whitespace_nowrap() + .when(linked, |el| el.underline().cursor(CursorStyle::PointingHand)) + .child(text); + if !interactive { + return span.into_any_element(); } - }) - .on_mouse_move({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { - if term.selecting { - term.extend_selection(row, end_col); - cx.notify(); + span.on_mouse_down(MouseButton::Left, { + let view = view.clone(); + move |ev, _, cx| { + let extend = ev.modifiers.shift; + view.update(cx, |app, cx| { + if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + term.begin_selection(row, start_col, extend); + term.click_cell = Some((row, start_col)); } - } - }); - } - }) - .into_any_element() - })) + cx.notify(); + }); + } + }) + .on_mouse_move({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(term) = app.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { + if term.selecting { + term.extend_selection(row, end_col); + cx.notify(); + } + } + }); + } + }) + .into_any_element() + })) })) } +pub fn fallback_pty_cell(font_size: f32) -> (f32, f32) { + let font = font_size.max(10.0); + (font * 0.6, font * 1.35) +} + +pub fn measure_pty_cell(window: &Window, font_family: impl Into, font_size: f32) -> (f32, f32) { + let font_size = font_size.max(10.0); + let (fallback_w, fallback_h) = fallback_pty_cell(font_size); + let run = TextRun { + len: 1, + font: crate::ui::markdown::editor_font(font_family), + color: rgb(0xffffff).into(), + background_color: None, + underline: None, + strikethrough: None, + }; + let line = window.text_system().layout_line("W", px(font_size), &[run], None); + let width = f32::from(line.width); + let height = f32::from(line.ascent) + f32::from(line.descent); + ( + if width > 0.0 { width } else { fallback_w }, + if height > 0.0 { height } else { fallback_h }, + ) +} + +pub fn pty_grid_size( + viewport_w: f32, + viewport_h: f32, + cell_w: f32, + cell_h: f32, + sidebar_collapsed: bool, + sidebar_width: f32, + profile_sidebar_open: bool, + profile_sidebar_width: f32, +) -> (u16, u16) { + let mut chrome = 48.0; + if !sidebar_collapsed { + chrome += sidebar_width; + } + if profile_sidebar_open { + chrome += profile_sidebar_width; + } + let cols = ((viewport_w - chrome - 24.0) / cell_w.max(1.0)).floor() as i32; + let rows = ((viewport_h - 96.0) / cell_h.max(1.0)).floor() as i32; + (rows.clamp(10, 120) as u16, cols.clamp(40, 300) as u16) +} + +pub fn cell_display_cols(wide: bool, continuation: bool) -> Option { + if continuation { + None + } else if wide { + Some(2) + } else { + Some(1) + } +} + +pub fn merge_row_spans(cells: &[(String, bool, bool, S)]) -> Vec<(String, u16, usize, S)> { + let mut spans: Vec<(String, u16, usize, S)> = Vec::new(); + for (col, (text, wide, continuation, style)) in cells.iter().enumerate() { + let Some(cols) = cell_display_cols(*wide, *continuation) else { + continue; + }; + let ch = if text.is_empty() { " ".to_string() } else { text.clone() }; + if let Some((last_text, last_cols, _, last_style)) = spans.last_mut() { + if *last_style == *style { + last_text.push_str(&ch); + *last_cols += cols; + continue; + } + } + spans.push((ch, cols, col, *style)); + } + spans +} + fn map_color(color: vt100::Color, fallback: u32) -> u32 { match color { vt100::Color::Default => fallback, @@ -541,3 +628,52 @@ fn key_to_bytes(ev: &KeyDownEvent) -> Option> { _ => None, } } + +#[cfg(test)] +mod tests { + use super::{cell_display_cols, fallback_pty_cell, merge_row_spans, pty_grid_size}; + + #[test] + fn fallback_pty_cell_matches_xterm_advance_ratio() { + assert_eq!(fallback_pty_cell(10.0), (6.0, 13.5)); + assert_eq!(fallback_pty_cell(20.0), (12.0, 27.0)); + } + + #[test] + fn pty_grid_size_uses_measured_cell_and_chrome() { + assert_eq!(pty_grid_size(800.0, 600.0, 8.0, 16.0, true, 0.0, false, 0.0), (31, 91)); + assert_eq!( + pty_grid_size(800.0, 600.0, 8.0, 16.0, false, 250.0, true, 208.0), + (31, 40) + ); + } + + #[test] + fn cell_display_cols_skips_wide_continuation() { + assert_eq!(cell_display_cols(false, false), Some(1)); + assert_eq!(cell_display_cols(true, false), Some(2)); + assert_eq!(cell_display_cols(false, true), None); + assert_eq!(cell_display_cols(true, true), None); + } + + #[test] + fn merge_row_spans_skips_continuation_and_spans_wide_glyphs() { + let cells = [ + ("a".into(), false, false, 1), + ("你".into(), true, false, 1), + ("".into(), false, true, 1), + ("b".into(), false, false, 1), + ]; + assert_eq!(merge_row_spans(&cells), vec![("a你b".into(), 4, 0, 1)]); + + let split = [ + ("你".into(), true, false, 1), + ("".into(), false, true, 1), + ("x".into(), false, false, 2), + ]; + assert_eq!( + merge_row_spans(&split), + vec![("你".into(), 2, 0, 1), ("x".into(), 1, 2, 2)] + ); + } +} diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index aee32063..0a0c51c9 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -415,6 +415,7 @@ fn profile_sidebar(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { .rounded_full() .bg(gpui::rgb(0x22c55e)) .cursor(gpui::CursorStyle::PointingHand) + .tooltip(crate::ui::tip("Dismiss completion notification")) .on_click({ let view = view.clone(); let id = id.clone(); From a06ac184de1e81f3569ce7e5235434db4d288484 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:16:07 +0000 Subject: [PATCH 33/50] Port leftover git review comments to structured GPUI queue cards. Comments now keep file, range, selected diff, and body. Copy uses the leftover agent prompt ("Please address these review comments"), the composer matches Comment on / Add to queue / Cancel, and the queue dialog shows leftover cards with Copy / Copy and clear all. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 101 +++++++++++++- src-gpui/src/diff.rs | 87 +++++++++++- src-gpui/src/main.rs | 1 + src-gpui/src/review.rs | 278 +++++++++++++++++++++++++++++++++++++ src-gpui/src/state.rs | 5 +- src-gpui/src/ui/dialogs.rs | 212 +++++++++++++++++++++++----- src-gpui/src/ui/git.rs | 209 ++++++++++++++++++++-------- 7 files changed, 797 insertions(+), 96 deletions(-) create mode 100644 src-gpui/src/review.rs diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index c887d4a7..fd7462ca 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -1818,18 +1818,110 @@ impl AppView { } } + pub fn select_review_line(&mut self, file: &str, side: crate::review::ReviewSide, line_no: u32, extend: bool) { + if line_no == 0 { + return; + } + let hunk = extract_file_hunk(&self.data.overlay.git_diff_text, file); + let prev_name = crate::diff::rename_paths(&hunk).map(|(old, _)| old); + let display_name = match &prev_name { + Some(prev) if prev != file => format!("{prev} -> {file}"), + _ => file.to_string(), + }; + let range = if extend { + if let Some(sel) = self.data.overlay.review_selection.as_ref().filter(|s| s.file == file) { + crate::review::ReviewRange { + start: sel.range.start, + end: line_no, + side: sel.range.side, + end_side: side, + } + } else { + crate::review::ReviewRange { + start: line_no, + end: line_no, + side, + end_side: side, + } + } + } else { + crate::review::ReviewRange { + start: line_no, + end: line_no, + side, + end_side: side, + } + }; + self.data.overlay.review_selection = Some(crate::review::ReviewSelection { + file: file.to_string(), + display_name, + prev_name, + range, + }); + } + + pub fn cancel_review_comment(&mut self, window: &mut Window, cx: &mut Context) { + self.data.overlay.review_selection = None; + self.inputs.review_comment.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + } + pub fn add_review_comment(&mut self, window: &mut Window, cx: &mut Context) { let body = self.inputs.review_comment.read(cx).value().to_string(); - let Some((file, line)) = self.data.overlay.review_line.clone() else { + let Some(sel) = self.data.overlay.review_selection.clone() else { return; }; if body.trim().is_empty() { return; } + let hunk = extract_file_hunk(&self.data.overlay.git_diff_text, &sel.file); + let selected_text = crate::review::selected_text_from_hunk(&hunk, sel.range); self.data .overlay .review_comments - .push(format!("{file}: {line}\n{body}")); + .push(crate::review::create_review_comment( + sel.file, + sel.prev_name, + sel.range, + selected_text, + body.trim(), + )); + self.cancel_review_comment(window, cx); + } + + pub fn delete_review_comment(&mut self, id: &str) { + self.data.overlay.review_comments.retain(|c| c.id != id); + if self.data.overlay.review_edit_id.as_deref() == Some(id) { + self.data.overlay.review_edit_id = None; + } + } + + pub fn begin_review_edit(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + self.flush_review_edit(window, cx); + let body = self + .data + .overlay + .review_comments + .iter() + .find(|c| c.id == id) + .map(|c| c.body.clone()) + .unwrap_or_default(); + self.data.overlay.review_edit_id = Some(id.to_string()); + self.inputs.review_comment.update(cx, |s, cx| { + s.set_value(&body, window, cx); + }); + } + + fn flush_review_edit(&mut self, window: &mut Window, cx: &mut Context) { + let Some(id) = self.data.overlay.review_edit_id.clone() else { + return; + }; + let body = self.inputs.review_comment.read(cx).value().to_string(); + if let Some(comment) = self.data.overlay.review_comments.iter_mut().find(|c| c.id == id) { + comment.body = body; + } + self.data.overlay.review_edit_id = None; self.inputs.review_comment.update(cx, |s, cx| { s.set_value("", window, cx); }); @@ -2125,8 +2217,9 @@ impl AppView { self.write_to_active_pty(quoted.as_bytes()); } - pub fn copy_review_comments(&mut self, clear: bool, cx: &mut App) { - let text = self.data.overlay.review_comments.join("\n"); + pub fn copy_review_comments(&mut self, clear: bool, window: &mut Window, cx: &mut Context) { + self.flush_review_edit(window, cx); + let text = crate::review::format_review_comments_for_agent(&self.data.overlay.review_comments); cx.write_to_clipboard(gpui::ClipboardItem::new_string(text)); if clear { self.data.overlay.review_comments.clear(); diff --git a/src-gpui/src/diff.rs b/src-gpui/src/diff.rs index 965f880e..d5e0f504 100644 --- a/src-gpui/src/diff.rs +++ b/src-gpui/src/diff.rs @@ -1,5 +1,5 @@ /// A single side of a split-diff row. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DiffLineKind { Context, Add, @@ -73,6 +73,77 @@ pub fn rename_paths(diff: &str) -> Option<(String, String)> { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AnnotatedLine { + pub raw: String, + pub kind: DiffLineKind, + pub old_no: Option, + pub new_no: Option, +} + +pub fn annotate_unified(diff: &str) -> Vec { + let mut old = 0u32; + let mut new = 0u32; + let mut lines = Vec::new(); + for line in diff.lines() { + if line.starts_with("@@") { + if let Some((o, n)) = parse_hunk_header(line) { + old = o; + new = n; + } + lines.push(AnnotatedLine { + raw: line.to_string(), + kind: DiffLineKind::Header, + old_no: None, + new_no: None, + }); + } else if is_header(line) { + lines.push(AnnotatedLine { + raw: line.to_string(), + kind: DiffLineKind::Header, + old_no: None, + new_no: None, + }); + } else if line.starts_with('+') { + lines.push(AnnotatedLine { + raw: line.to_string(), + kind: DiffLineKind::Add, + old_no: None, + new_no: Some(new), + }); + new = new.saturating_add(1); + } else if line.starts_with('-') { + lines.push(AnnotatedLine { + raw: line.to_string(), + kind: DiffLineKind::Del, + old_no: Some(old), + new_no: None, + }); + old = old.saturating_add(1); + } else { + lines.push(AnnotatedLine { + raw: line.to_string(), + kind: DiffLineKind::Context, + old_no: Some(old), + new_no: Some(new), + }); + old = old.saturating_add(1); + new = new.saturating_add(1); + } + } + lines +} + +pub fn parse_hunk_header(line: &str) -> Option<(u32, u32)> { + let rest = line.strip_prefix("@@ ")?; + let mut parts = rest.split_whitespace(); + let old = parts.next()?.strip_prefix('-')?; + let new = parts.next()?.strip_prefix('+')?; + let old_n = old.split(',').next()?.parse().ok()?; + let new_n = new.split(',').next()?.parse().ok()?; + Some((old_n, new_n)) +} + fn is_header(line: &str) -> bool { line.starts_with("diff ") || line.starts_with("index ") @@ -146,4 +217,18 @@ mod tests { assert_eq!(rename_paths(diff), Some(("old.rs".into(), "new.rs".into()))); assert_eq!(rename_paths("diff --git a/a.rs b/a.rs\n"), None); } + + #[test] + fn annotate_unified_tracks_old_and_new_line_numbers() { + let lines = annotate_unified("@@ -10,2 +12,3 @@\n keep\n-old\n+new\n+more\n"); + assert_eq!(parse_hunk_header("@@ -10,2 +12,3 @@ fn"), Some((10, 12))); + assert_eq!(lines[1].kind, DiffLineKind::Context); + assert_eq!(lines[1].old_no, Some(10)); + assert_eq!(lines[1].new_no, Some(12)); + assert_eq!(lines[2].kind, DiffLineKind::Del); + assert_eq!(lines[2].old_no, Some(11)); + assert_eq!(lines[3].kind, DiffLineKind::Add); + assert_eq!(lines[3].new_no, Some(13)); + assert_eq!(lines[4].new_no, Some(14)); + } } diff --git a/src-gpui/src/main.rs b/src-gpui/src/main.rs index cd191bec..847eb656 100644 --- a/src-gpui/src/main.rs +++ b/src-gpui/src/main.rs @@ -5,6 +5,7 @@ mod diff; mod i18n; mod platform; mod prefs; +mod review; mod state; mod ui; mod updater; diff --git a/src-gpui/src/review.rs b/src-gpui/src/review.rs new file mode 100644 index 00000000..52df49d8 --- /dev/null +++ b/src-gpui/src/review.rs @@ -0,0 +1,278 @@ +use crate::diff::{self, DiffLineKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ReviewSide { + #[default] + Additions, + Deletions, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReviewRange { + pub start: u32, + pub end: u32, + pub side: ReviewSide, + pub end_side: ReviewSide, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReviewComment { + pub id: String, + pub file_name: String, + pub display_name: String, + pub prev_name: Option, + pub range: ReviewRange, + pub selected_text: String, + pub body: String, + pub created_at: u128, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReviewSelection { + pub file: String, + pub display_name: String, + pub prev_name: Option, + pub range: ReviewRange, +} + +pub fn format_review_range(range: ReviewRange) -> String { + let start = range.start.min(range.end); + let end = range.start.max(range.end); + if start == end { + format!("{start}") + } else { + format!("{start}-{end}") + } +} + +pub fn normalize_clipboard_block(text: &str) -> String { + let mut lines: Vec = text.split('\n').map(|line| line.trim_end().to_string()).collect(); + while lines.first().is_some_and(|l| l.is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|l| l.is_empty()) { + lines.pop(); + } + lines.join("\n") +} + +pub fn selected_text_from_sides(additions: &[&str], deletions: &[&str], range: ReviewRange) -> String { + let start = range.start.min(range.end); + let end = range.start.max(range.end); + if range.side == range.end_side { + return side_lines( + if range.side == ReviewSide::Additions { + additions + } else { + deletions + }, + range.side, + start, + end, + ); + } + let deletion_text = side_lines(deletions, ReviewSide::Deletions, start, end); + let addition_text = side_lines(additions, ReviewSide::Additions, start, end); + [ + if deletion_text.is_empty() { + String::new() + } else { + format!("# deletions\n{deletion_text}") + }, + if addition_text.is_empty() { + String::new() + } else { + format!("# additions\n{addition_text}") + }, + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n") +} + +fn side_lines(lines: &[&str], side: ReviewSide, start: u32, end: u32) -> String { + let prefix = match side { + ReviewSide::Additions => '+', + ReviewSide::Deletions => '-', + }; + let start_ix = start.saturating_sub(1) as usize; + let end_ix = end as usize; + lines + .get(start_ix..end_ix.min(lines.len())) + .unwrap_or(&[]) + .iter() + .map(|line| format!("{prefix}{}", line.trim_end())) + .collect::>() + .join("\n") +} + +pub fn selected_text_from_hunk(hunk: &str, range: ReviewRange) -> String { + let annotated = diff::annotate_unified(hunk); + let additions: Vec = annotated + .iter() + .filter(|l| l.kind == DiffLineKind::Add) + .map(|l| strip_diff_prefix(&l.raw)) + .collect(); + let deletions: Vec = annotated + .iter() + .filter(|l| l.kind == DiffLineKind::Del) + .map(|l| strip_diff_prefix(&l.raw)) + .collect(); + let add_refs: Vec<&str> = additions.iter().map(String::as_str).collect(); + let del_refs: Vec<&str> = deletions.iter().map(String::as_str).collect(); + selected_text_from_sides(&add_refs, &del_refs, range) +} + +fn strip_diff_prefix(raw: &str) -> String { + raw.chars().next().map(|_| raw[1..].to_string()).unwrap_or_default() +} + +pub fn create_review_comment( + file_name: impl Into, + prev_name: Option, + range: ReviewRange, + selected_text: String, + body: &str, +) -> ReviewComment { + let file_name = file_name.into(); + let display_name = match &prev_name { + Some(prev) if prev != &file_name => format!("{prev} -> {file_name}"), + _ => file_name.clone(), + }; + ReviewComment { + id: uuid::Uuid::new_v4().to_string(), + file_name, + display_name, + prev_name, + range, + selected_text, + body: body.to_string(), + created_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + } +} + +pub fn format_review_comments_for_agent(comments: &[ReviewComment]) -> String { + let mut lines = vec!["Please address these review comments:".to_string(), String::new()]; + for (index, comment) in comments.iter().enumerate() { + lines.push(format!( + "{}. {}:{}", + index + 1, + comment.file_name, + format_review_range(comment.range) + )); + lines.push("Selected diff:".into()); + lines.push("```diff".into()); + let selected = normalize_clipboard_block(&comment.selected_text); + lines.push(if selected.is_empty() { + "(no selected text available)".into() + } else { + selected + }); + lines.push("```".into()); + lines.push("Comment:".into()); + lines.push(normalize_clipboard_block(&comment.body)); + lines.push(String::new()); + } + lines.join("\n") +} + +pub fn line_in_range(range: ReviewRange, side: ReviewSide, line_no: u32) -> bool { + let start = range.start.min(range.end); + let end = range.start.max(range.end); + if line_no < start || line_no > end { + return false; + } + if range.side == range.end_side { + range.side == side + } else { + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_review_range_collapses_single_line() { + assert_eq!( + format_review_range(ReviewRange { + start: 4, + end: 4, + side: ReviewSide::Additions, + end_side: ReviewSide::Additions, + }), + "4" + ); + assert_eq!( + format_review_range(ReviewRange { + start: 3, + end: 1, + side: ReviewSide::Additions, + end_side: ReviewSide::Additions, + }), + "1-3" + ); + } + + #[test] + fn copies_selected_diff_without_extra_prefix_spaces_or_trailing_whitespace() { + let comment = create_review_comment( + "src/example.ts", + None, + ReviewRange { + start: 1, + end: 3, + side: ReviewSide::Additions, + end_side: ReviewSide::Additions, + }, + selected_text_from_sides( + &[" const value = 1; ", "", " return value;"], + &[], + ReviewRange { + start: 1, + end: 3, + side: ReviewSide::Additions, + end_side: ReviewSide::Additions, + }, + ), + "\n tighten this up \n\n", + ); + assert!(format_review_comments_for_agent(&[comment]).contains( + [ + "1. src/example.ts:1-3", + "Selected diff:", + "```diff", + "+ const value = 1;", + "+", + "+ return value;", + "```", + "Comment:", + " tighten this up", + ] + .join("\n") + .as_str() + )); + } + + #[test] + fn display_name_uses_rename_arrow() { + let comment = create_review_comment( + "new.rs", + Some("old.rs".into()), + ReviewRange { + start: 1, + end: 1, + side: ReviewSide::Additions, + end_side: ReviewSide::Additions, + }, + "+fn main() {}".into(), + "ok", + ); + assert_eq!(comment.display_name, "old.rs -> new.rs"); + } +} diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 43a2a903..f311de85 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -601,8 +601,9 @@ pub struct OverlayState { pub git_commit_files: Vec, pub git_selected_commit: Option, pub git_large_revealed: HashSet, - pub review_comments: Vec, - pub review_line: Option<(String, String)>, + pub review_comments: Vec, + pub review_selection: Option, + pub review_edit_id: Option, pub branches: Vec, pub fuzzy_files: Vec, pub onboarding: bool, diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index d5867180..b05bd4f3 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -61,16 +61,16 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> .child( v_flex() .id("dialog-panel") - .w(px( - if matches!( - kind, - DialogKind::ProjectSettings | DialogKind::SwitchBranch | DialogKind::DebugLog - ) { - 560. - } else { - 380. - }, - )) + .w(px(if matches!(kind, DialogKind::ReviewQueue) { + 720. + } else if matches!( + kind, + DialogKind::ProjectSettings | DialogKind::SwitchBranch | DialogKind::DebugLog + ) { + 560. + } else { + 380. + })) .max_h(px(640.)) .p_4() .gap_3() @@ -97,6 +97,9 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> kind == DialogKind::DeleteProfile || kind == DialogKind::DeleteProject, |el| el.child(Icon::new(IconName::TriangleAlert).w(px(18.))), ) + .when(kind == DialogKind::ReviewQueue, |el| { + el.child(Icon::new(IconName::Inbox).w(px(18.))) + }) .child(div().font_semibold().child(title)), ) .child(Button::new("dlg-x").ghost().xsmall().icon(IconName::Close).on_click({ @@ -565,13 +568,7 @@ fn dialog_body( .child(Input::new(&app.inputs.template_cwd)) .child(Input::new(&app.inputs.template_commands)) .into_any_element(), - DialogKind::ReviewQueue => v_flex() - .gap_2() - .children(app.data.overlay.review_comments.iter().map(|c| div().child(c.clone()))) - .when(app.data.overlay.review_comments.is_empty(), |el| { - el.child(div().text_color(theme.muted_foreground).child("—")) - }) - .into_any_element(), + DialogKind::ReviewQueue => review_queue_body(app, theme.muted_foreground, cx).into_any_element(), DialogKind::DebugLog => crate::ui::debug::render_panel(app, _window, cx).into_any_element(), DialogKind::CreateGroup => v_flex() .gap_2() @@ -581,6 +578,137 @@ fn dialog_body( } } +fn review_queue_body(app: &AppView, muted: gpui::Hsla, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let theme = cx.theme().clone(); + let editing = app.data.overlay.review_edit_id.clone(); + v_flex() + .id("review-queue") + .gap_3() + .max_h(px(480.)) + .children(app.data.overlay.review_comments.iter().cloned().map(|comment| { + let id = comment.id.clone(); + let editing_this = editing.as_deref() == Some(id.as_str()); + v_flex() + .id(crate::ui::eid(format!("review-card-{id}"))) + .gap_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .child( + h_flex() + .px_3() + .py_2() + .gap_2() + .border_b_1() + .border_color(theme.border) + .bg(theme.muted) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + div() + .font_family("monospace") + .font_semibold() + .text_sm() + .child(comment.display_name.clone()), + ) + .child( + h_flex() + .gap_2() + .child( + div() + .px_1() + .text_xs() + .font_family("monospace") + .rounded_md() + .bg(gpui::hsla(0.58, 0.4, 0.5, 0.18)) + .child(crate::review::format_review_range(comment.range)), + ) + .child(div().text_xs().text_color(muted).child("Selected diff")), + ), + ) + .child( + Button::new(crate::ui::eid(format!("review-del-{id}"))) + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip("Delete review comment".to_string()) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.delete_review_comment(&id); + cx.notify(); + }); + } + }), + ), + ) + .child( + div() + .mx_3() + .max_h(px(128.)) + .overflow_hidden() + .rounded_md() + .border_1() + .border_color(theme.border) + .p_2() + .font_family("monospace") + .text_xs() + .child(if comment.selected_text.is_empty() { + "(no selected text available)".to_string() + } else { + comment.selected_text.clone() + }), + ) + .child( + div() + .px_3() + .text_xs() + .font_semibold() + .text_color(muted) + .child("Comment"), + ) + .child(if editing_this { + div() + .px_3() + .pb_3() + .child(Input::new(&app.inputs.review_comment)) + .into_any_element() + } else { + div() + .id(crate::ui::eid(format!("review-body-{id}"))) + .px_3() + .pb_3() + .text_sm() + .cursor(gpui::CursorStyle::PointingHand) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.begin_review_edit(&id, window, cx); + cx.notify(); + }); + } + }) + .child(if comment.body.is_empty() { + " ".to_string() + } else { + comment.body + }) + .into_any_element() + }) + })) + .when(app.data.overlay.review_comments.is_empty(), |el| { + el.child(div().text_color(muted).child("—")) + }) +} + fn badge(text: &str, color: gpui::Hsla) -> impl IntoElement { div().px_1().text_xs().rounded_md().bg(color).child(text.to_string()) } @@ -607,37 +735,56 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> DialogKind::CloseUnsaved => app.t("discardChanges"), DialogKind::OpenLink => app.t("browserOpenDefault"), DialogKind::CreateGroup => app.t("create"), - DialogKind::ReviewQueue => app.t("reviewCommentsCopied"), DialogKind::EditTemplate => app.t("save"), _ => app.t("cancel"), }; let show_ok = !matches!( kind, - DialogKind::SwitchBranch | DialogKind::DebugLog | DialogKind::ChooseFile + DialogKind::SwitchBranch | DialogKind::DebugLog | DialogKind::ChooseFile | DialogKind::ReviewQueue ); + let review_empty = app.data.overlay.review_comments.is_empty(); h_flex() .justify_end() .gap_2() - .child(Button::new("dlg-cancel").small().label(app.t("cancel")).on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.dialog = None; - cx.notify(); - }); - } - })) + .when(kind != DialogKind::ReviewQueue, |el| { + el.child(Button::new("dlg-cancel").small().label(app.t("cancel")).on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + cx.notify(); + }); + } + })) + }) .when(kind == DialogKind::ReviewQueue, |el| { el.child( + Button::new("dlg-copy") + .small() + .label("Copy".to_string()) + .disabled(review_empty) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.copy_review_comments(false, window, cx); + cx.notify(); + }); + } + }), + ) + .child( Button::new("dlg-copy-clear") .small() - .label(app.t("reviewCommentsCopiedAndCleared")) + .danger() + .label("Copy and clear all".to_string()) + .disabled(review_empty) .on_click({ let view = view.clone(); - move |_, _, cx| { + move |_, window, cx| { view.update(cx, |app, cx| { - app.copy_review_comments(true, cx); + app.copy_review_comments(true, window, cx); cx.notify(); }); } @@ -692,9 +839,6 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> DialogKind::CreateGroup => { app.submit_create_group(None, cx); } - DialogKind::ReviewQueue => { - app.copy_review_comments(false, cx); - } _ => app.data.overlay.dialog = None, } cx.notify(); diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index 90035148..7d781980 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -557,6 +557,7 @@ pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Cont mode, theme.muted_foreground, selected.clone().unwrap_or_default(), + app.data.overlay.review_selection.clone(), &view, )) .child(review_composer(app, cx)) @@ -570,11 +571,7 @@ pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Cont Button::new("open-review") .primary() .small() - .label(format!( - "{} ({})", - app.t("reviewQueue"), - app.data.overlay.review_comments.len() - )) + .label(format!("Review Queue ({})", app.data.overlay.review_comments.len())) .on_click({ let view = view.clone(); move |_, _, cx| { @@ -717,38 +714,76 @@ fn diff_stats(diff: &str) -> impl IntoElement { fn review_composer(app: &AppView, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); - let selected = app - .data - .overlay - .review_line - .as_ref() - .map(|(_, line)| line.clone()) - .unwrap_or_default(); + let Some(sel) = app.data.overlay.review_selection.clone() else { + return div().id("review-composer-idle").into_any_element(); + }; + let theme = cx.theme().clone(); v_flex() - .gap_1() - .pt_2() - .border_t_1() - .border_color(cx.theme().border) - .when(!selected.is_empty(), |el| { - el.child(div().text_xs().font_family("monospace").child(selected)) - }) - .child(Input::new(&app.inputs.review_comment)) + .id("review-composer") + .w(px(448.)) + .mt_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_lg() .child( - Button::new("add-review") - .small() - .primary() - .label(app.t("reviewQueue")) - .disabled(app.data.overlay.review_line.is_none()) - .on_click({ - let view = view.clone(); - move |_, window, cx| { - view.update(cx, |app, cx| { - app.add_review_comment(window, cx); - cx.notify(); - }); - } - }), + h_flex() + .px_3() + .py_2() + .justify_between() + .border_b_1() + .border_color(theme.border) + .child( + div() + .text_xs() + .font_semibold() + .text_color(theme.muted_foreground) + .child(format!("Comment on {}", crate::review::format_review_range(sel.range))), + ) + .child( + Button::new("cancel-review") + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip("Cancel review comment".to_string()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.cancel_review_comment(window, cx); + cx.notify(); + }); + } + }), + ), ) + .child(div().px_1().child(Input::new(&app.inputs.review_comment))) + .child( + h_flex() + .justify_end() + .px_3() + .py_2() + .border_t_1() + .border_color(theme.border) + .child( + Button::new("add-review") + .small() + .primary() + .label("Add to queue".to_string()) + .disabled(app.inputs.review_comment.read(cx).value().trim().is_empty()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.add_review_comment(window, cx); + cx.notify(); + }); + } + }), + ), + ) + .into_any_element() } fn diff_view( @@ -756,49 +791,66 @@ fn diff_view( mode: DiffPreviewMode, muted: gpui::Hsla, file: String, + selection: Option, view: &gpui::Entity, ) -> impl IntoElement { if diff.trim().is_empty() { return div().text_color(muted).child("").into_any_element(); } if mode == DiffPreviewMode::Split { - return split_diff_view(diff, muted, file, view).into_any_element(); + return split_diff_view(diff, muted, file, selection, view).into_any_element(); } + let annotated = diff::annotate_unified(diff); v_flex() .id("diff-lines") .gap_0() .font_family("monospace") .text_sm() .children( - diff.lines() + annotated + .into_iter() .enumerate() - .map(|(ix, line)| unified_line(line, muted, file.clone(), ix, view)), + .map(|(ix, line)| unified_line(line, muted, file.clone(), selection.as_ref(), ix, view)), ) .into_any_element() } fn unified_line( - line: &str, + line: diff::AnnotatedLine, muted: gpui::Hsla, file: String, + selection: Option<&crate::review::ReviewSelection>, ix: usize, view: &gpui::Entity, ) -> impl IntoElement { - let (bg, color) = line_colors(line, muted); - let text = line.to_string(); + let (mut bg, color) = line_colors(&line.raw, muted); + let target = match line.kind { + DiffLineKind::Add => line.new_no.map(|no| (crate::review::ReviewSide::Additions, no)), + DiffLineKind::Del => line.old_no.map(|no| (crate::review::ReviewSide::Deletions, no)), + _ => None, + }; + if let Some((side, no)) = target { + if selection.is_some_and(|s| s.file == file && crate::review::line_in_range(s.range, side, no)) { + bg = gpui::hsla(0.58, 0.55, 0.45, 0.22); + } + } let view = view.clone(); + let text = line.raw; div() .id(crate::ui::eid(format!("udiff-{ix}"))) .px_2() .bg(bg) .text_color(color) - .on_click(move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.review_line = Some((file.clone(), text.clone())); - cx.notify(); - }); + .when_some(target, |el, (side, no)| { + el.on_click(move |ev, _, cx| { + let extend = ev.modifiers().shift; + view.update(cx, |app, cx| { + app.select_review_line(&file, side, no, extend); + cx.notify(); + }); + }) }) - .child(line.to_string()) + .child(text) } fn line_colors(line: &str, muted: gpui::Hsla) -> (gpui::Hsla, gpui::Hsla) { @@ -820,7 +872,14 @@ fn kind_colors(kind: DiffLineKind, muted: gpui::Hsla) -> (gpui::Hsla, gpui::Hsla } } -fn split_diff_view(diff: &str, muted: gpui::Hsla, file: String, view: &gpui::Entity) -> impl IntoElement { +fn split_diff_view( + diff: &str, + muted: gpui::Hsla, + file: String, + selection: Option, + view: &gpui::Entity, +) -> impl IntoElement { + let annotated = diff::annotate_unified(diff); v_flex() .id("diff-split") .gap_0() @@ -830,29 +889,68 @@ fn split_diff_view(diff: &str, muted: gpui::Hsla, file: String, view: &gpui::Ent h_flex() .id(crate::ui::eid(format!("split-row-{ix}"))) .w_full() - .child(split_cell(row.left, muted, ix, "l", file.clone(), view)) + .child(split_cell( + row.left, + muted, + ix, + "l", + file.clone(), + selection.as_ref(), + &annotated, + view, + )) .child(div().w(px(1.)).h_full().bg(gpui::hsla(0., 0., 0.5, 0.2))) - .child(split_cell(row.right, muted, ix, "r", file.clone(), view)) + .child(split_cell( + row.right, + muted, + ix, + "r", + file.clone(), + selection.as_ref(), + &annotated, + view, + )) })) } +fn split_target( + kind: DiffLineKind, + text: &str, + annotated: &[diff::AnnotatedLine], +) -> Option<(crate::review::ReviewSide, u32)> { + let line = annotated.iter().find(|l| l.kind == kind && l.raw == text)?; + match kind { + DiffLineKind::Add => Some((crate::review::ReviewSide::Additions, line.new_no?)), + DiffLineKind::Del => Some((crate::review::ReviewSide::Deletions, line.old_no?)), + _ => None, + } +} + fn split_cell( cell: Option<(DiffLineKind, String)>, muted: gpui::Hsla, ix: usize, side: &'static str, file: String, + selection: Option<&crate::review::ReviewSelection>, + annotated: &[diff::AnnotatedLine], view: &gpui::Entity, ) -> impl IntoElement { let (kind, text) = match cell { Some((kind, text)) => (Some(kind), text), None => (None, String::new()), }; - let (bg, color) = kind + let (mut bg, color) = kind .map(|k| kind_colors(k, muted)) .unwrap_or((gpui::hsla(0., 0., 0., 0.), muted)); + let target = kind.and_then(|k| split_target(k, &text, annotated)); + if let Some((review_side, no)) = target { + if selection.is_some_and(|s| s.file == file && crate::review::line_in_range(s.range, review_side, no)) { + bg = gpui::hsla(0.58, 0.55, 0.45, 0.22); + } + } let view = view.clone(); - let shown = if text.is_empty() { " ".to_string() } else { text.clone() }; + let shown = if text.is_empty() { " ".to_string() } else { text }; div() .id(crate::ui::eid(format!("split-{side}-{ix}"))) .flex_1() @@ -860,13 +958,14 @@ fn split_cell( .px_2() .bg(bg) .text_color(color) - .on_click(move |_, _, cx| { - if !text.is_empty() { + .when_some(target, |el, (review_side, no)| { + el.on_click(move |ev, _, cx| { + let extend = ev.modifiers().shift; view.update(cx, |app, cx| { - app.data.overlay.review_line = Some((file.clone(), text.clone())); + app.select_review_line(&file, review_side, no, extend); cx.notify(); }); - } + }) }) .child(shown) } From 6b32d8152346865cf23c8d646696871bff19e25d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:18:50 +0000 Subject: [PATCH 34/50] Match GPUI git history to the leftover commit list and drill-in. Commit rows now show leftover hardcoded relative time, mono hash, author, and file/+/- stats. Opening a commit uses the leftover back-arrow header, message and hash, and "{count} changed file(s)" list with basename/parent and selection. Co-authored-by: AkaraChen --- src-gpui/src/ui/git.rs | 281 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 254 insertions(+), 27 deletions(-) diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index 7d781980..7abc1495 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -609,36 +609,188 @@ fn tab_btn( }) } +pub fn format_relative_time(iso: &str, now_secs: i64) -> String { + let Some(then) = parse_iso8601_secs(iso) else { + return iso.to_string(); + }; + let diff_sec = now_secs - then; + if diff_sec < 60 { + return "just now".into(); + } + let diff_min = diff_sec / 60; + if diff_min < 60 { + return format!("{diff_min}m ago"); + } + let diff_hr = diff_min / 60; + if diff_hr < 24 { + return format!("{diff_hr}h ago"); + } + let diff_day = diff_hr / 24; + if diff_day < 30 { + return format!("{diff_day}d ago"); + } + let diff_month = diff_day / 30; + if diff_month < 12 { + return format!("{diff_month}mo ago"); + } + format!("{}y ago", diff_month / 12) +} + +fn parse_iso8601_secs(raw: &str) -> Option { + let s = raw.trim(); + let (date, time_tz) = s.split_once('T')?; + let mut dp = date.split('-'); + let year: i32 = dp.next()?.parse().ok()?; + let month: i32 = dp.next()?.parse().ok()?; + let day: i32 = dp.next()?.parse().ok()?; + let (time, offset) = if let Some(time) = time_tz.strip_suffix('Z').or_else(|| time_tz.strip_suffix('z')) { + (time, 0i64) + } else if let Some(idx) = time_tz.rfind('+') { + (&time_tz[..idx], parse_tz_offset(&time_tz[idx..])?) + } else if let Some(rel) = time_tz.find('-').filter(|&i| i >= 8) { + (&time_tz[..rel], parse_tz_offset(&time_tz[rel..])?) + } else { + (time_tz, 0) + }; + let time = time.split('.').next()?; + let mut tp = time.split(':'); + let hour: i64 = tp.next()?.parse().ok()?; + let minute: i64 = tp.next()?.parse().ok()?; + let second: i64 = tp.next().unwrap_or("0").parse().ok()?; + Some(civil_to_unix(year, month, day) + hour * 3600 + minute * 60 + second - offset) +} + +fn parse_tz_offset(raw: &str) -> Option { + let (sign, rest) = if let Some(rest) = raw.strip_prefix('+') { + (1i64, rest) + } else if let Some(rest) = raw.strip_prefix('-') { + (-1, rest) + } else { + return None; + }; + let compact = rest.replace(':', ""); + if compact.len() < 2 { + return None; + } + let hours: i64 = compact[..2].parse().ok()?; + let minutes: i64 = if compact.len() >= 4 { + compact[2..4].parse().ok()? + } else { + 0 + }; + Some(sign * (hours * 3600 + minutes * 60)) +} + +fn civil_to_unix(year: i32, month: i32, day: i32) -> i64 { + let (mut year, mut month) = (year, month); + if month <= 2 { + year -= 1; + month += 9; + } else { + month -= 3; + } + let era = if year >= 0 { year } else { year - 399 } / 400; + let yoe = (year - era * 400) as u32; + let doy = (153 * month as u32 + 2) / 5 + day as u32 - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + (era as i64 * 146097 + doe as i64 - 719468) * 86400 +} + +fn unix_now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); let theme = cx.theme().clone(); if let Some(hash) = app.data.overlay.git_selected_commit.clone() { + let commit = app + .data + .overlay + .git_commits + .iter() + .find(|c| c.hash == hash || c.full_hash == hash) + .cloned(); + let selected_file = app.data.overlay.git_diff_file.clone(); return v_flex() + .id("commit-drill-in") .child( - Button::new("back-commits") - .ghost() - .small() - .label(app.t("backToCommitList")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.git_selected_commit = None; - cx.notify(); - }); - } - }), + h_flex() + .px_2() + .py_1() + .gap_1() + .child( + Button::new("back-commits") + .ghost() + .xsmall() + .icon(IconName::ArrowLeft) + .tooltip(app.t("backToCommitList")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.git_selected_commit = None; + cx.notify(); + }); + } + }), + ) + .child( + v_flex() + .min_w_0() + .flex_1() + .child( + div() + .text_sm() + .font_medium() + .child(commit.as_ref().map(|c| c.message.clone()).unwrap_or_default()), + ) + .child( + div() + .text_xs() + .font_family("monospace") + .text_color(theme.muted_foreground) + .child(commit.as_ref().map(|c| c.hash.clone()).unwrap_or(hash.clone())), + ), + ), ) .child(if app.data.overlay.git_commit_files.is_empty() { - div().p_3().child(app.t("noFileChanges")).into_any_element() + div() + .p_8() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noFileChanges")) + .into_any_element() } else { v_flex() + .child( + div() + .px_3() + .py_1() + .text_xs() + .text_color(theme.muted_foreground) + .child(crate::i18n::tf( + app.data.locale, + "changedFiles", + &[("count", &app.data.overlay.git_commit_files.len().to_string())], + )), + ) .children(app.data.overlay.git_commit_files.iter().map(|p| { let path = p.clone(); - div() + let active = selected_file.as_deref() == Some(p.as_str()); + let (name, parent) = match p.rsplit_once('/') { + Some((dir, base)) => (base.to_string(), Some(dir.to_string())), + None => (p.clone(), None), + }; + h_flex() .id(crate::ui::eid(format!("cfile-{p}"))) - .px_2() - .py_1() + .px_3() + .py_2() + .gap_2() + .when(active, |el| el.bg(theme.muted)) .hover(|el| el.bg(theme.muted)) .on_click({ let view = view.clone(); @@ -649,23 +801,33 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { }); } }) - .child(p.clone()) + .child(div().text_sm().when(active, |el| el.font_medium()).child(name)) + .when_some(parent, |el, dir| { + el.child(div().text_xs().text_color(theme.muted_foreground).child(dir)) + }) })) .into_any_element() }) .into_any_element(); } if app.data.overlay.git_commits.is_empty() { - return div().p_4().child(app.t("noCommitsFound")).into_any_element(); + return div() + .p_8() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noCommitsFound")) + .into_any_element(); } + let now = unix_now_secs(); v_flex() + .id("commit-list") .children(app.data.overlay.git_commits.iter().map(|c| { let hash = c.hash.clone(); v_flex() .id(crate::ui::eid(format!("commit-{hash}"))) .px_3() - .py_2() - .gap_1() + .py(px(6.)) + .gap(px(2.)) .hover(|el| el.bg(theme.muted)) .on_click({ let view = view.clone(); @@ -677,12 +839,42 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { }); } }) - .child(div().font_medium().text_sm().child(c.message.clone())) - .child(div().text_xs().text_color(theme.muted_foreground).child(format!( - "{} · {}", - c.author.name, - &hash[..hash.len().min(7)] - ))) + .child(div().text_sm().child(c.message.clone())) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(theme.muted_foreground) + .child(div().font_family("monospace").child(hash.clone())) + .child(div().min_w_0().flex_1().child(c.author.name.clone())) + .child(div().child(format_relative_time(&c.date, now))), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .when(c.files_changed > 0, |el| { + el.child(div().text_color(theme.muted_foreground).child(format!( + "{} {}", + c.files_changed, + if c.files_changed == 1 { "file" } else { "files" } + ))) + }) + .when(c.insertions > 0, |el| { + el.child( + div() + .text_color(gpui::hsla(0.38, 0.7, 0.45, 1.)) + .child(format!("+{}", c.insertions)), + ) + }) + .when(c.deletions > 0, |el| { + el.child( + div() + .text_color(gpui::hsla(0.02, 0.75, 0.5, 1.)) + .child(format!("-{}", c.deletions)), + ) + }), + ) })) .into_any_element() } @@ -1016,3 +1208,38 @@ fn image_preview(app: &AppView, path: &str, diff: &str) -> impl IntoElement { }), ) } + +#[cfg(test)] +mod tests { + use super::{civil_to_unix, format_relative_time, parse_iso8601_secs}; + + #[test] + fn format_relative_time_matches_leftover_commit_list() { + let then = parse_iso8601_secs("2026-04-09T12:00:00Z").unwrap(); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 30), "just now"); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 5 * 60), "5m ago"); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 3 * 3600), "3h ago"); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 2 * 86400), "2d ago"); + assert_eq!( + format_relative_time("2026-04-09T12:00:00Z", then + 30 * 86400), + "1mo ago" + ); + assert_eq!( + format_relative_time("2026-04-09T12:00:00Z", then + 400 * 86400), + "1y ago" + ); + } + + #[test] + fn parse_iso8601_respects_timezone_offset() { + let utc = parse_iso8601_secs("2026-04-09T12:00:00Z").unwrap(); + let plus8 = parse_iso8601_secs("2026-04-09T20:00:00+08:00").unwrap(); + assert_eq!(utc, plus8); + assert_eq!(utc, civil_to_unix(2026, 4, 9) + 12 * 3600); + } + + #[test] + fn format_relative_time_falls_back_to_raw_date() { + assert_eq!(format_relative_time("not-a-date", 0), "not-a-date"); + } +} From d98adf484b9d5ee7ce105a38e67015bf4a28e405 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:27:06 +0000 Subject: [PATCH 35/50] Match GPUI git file rows and About dates to leftover FileListItem. Git changes and history lists now use leftover basename + parent path rows, outline A/D/M/R badges, dim unchecked files, and discard only from the right-click menu. About stores the raw release timestamp and formats it as a leftover medium date plus long relative time. Co-authored-by: AkaraChen --- src-gpui/src/main.rs | 1 + src-gpui/src/state.rs | 1 + src-gpui/src/timefmt.rs | 282 ++++++++++++++++++++++++++++++++ src-gpui/src/ui/dialogs.rs | 9 ++ src-gpui/src/ui/git.rs | 312 ++++++++++++++++-------------------- src-gpui/src/ui/settings.rs | 7 +- src-gpui/src/updater.rs | 24 +-- 7 files changed, 450 insertions(+), 186 deletions(-) create mode 100644 src-gpui/src/timefmt.rs diff --git a/src-gpui/src/main.rs b/src-gpui/src/main.rs index 847eb656..18462500 100644 --- a/src-gpui/src/main.rs +++ b/src-gpui/src/main.rs @@ -7,6 +7,7 @@ mod platform; mod prefs; mod review; mod state; +mod timefmt; mod ui; mod updater; diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index f311de85..5a100aa8 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -169,6 +169,7 @@ pub enum ContextMenu { Project { id: String }, Profile { id: String, project_id: String }, File { path: String }, + GitFile { path: String }, TreeBlank, NewTerminal, } diff --git a/src-gpui/src/timefmt.rs b/src-gpui/src/timefmt.rs new file mode 100644 index 00000000..54b7ec9a --- /dev/null +++ b/src-gpui/src/timefmt.rs @@ -0,0 +1,282 @@ +use crate::i18n::Locale; + +const EN_MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +pub fn unix_now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +pub fn unix_now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +pub fn parse_iso8601_secs(raw: &str) -> Option { + let s = raw.trim(); + if !s.contains('T') { + return parse_ymd(s).map(|(y, m, d)| civil_to_unix(y, m, d)); + } + let (date, time_tz) = s.split_once('T')?; + let (year, month, day) = parse_ymd(date)?; + let (time, offset) = if let Some(time) = time_tz.strip_suffix('Z').or_else(|| time_tz.strip_suffix('z')) { + (time, 0i64) + } else if let Some(idx) = time_tz.rfind('+') { + (&time_tz[..idx], parse_tz_offset(&time_tz[idx..])?) + } else if let Some(rel) = time_tz.find('-').filter(|&i| i >= 8) { + (&time_tz[..rel], parse_tz_offset(&time_tz[rel..])?) + } else { + (time_tz, 0) + }; + let time = time.split('.').next()?; + let mut tp = time.split(':'); + let hour: i64 = tp.next()?.parse().ok()?; + let minute: i64 = tp.next()?.parse().ok()?; + let second: i64 = tp.next().unwrap_or("0").parse().ok()?; + Some(civil_to_unix(year, month, day) + hour * 3600 + minute * 60 + second - offset) +} + +fn parse_ymd(raw: &str) -> Option<(i32, i32, i32)> { + let date = raw.get(..10)?; + if date.as_bytes().get(4) != Some(&b'-') || date.as_bytes().get(7) != Some(&b'-') { + return None; + } + Some(( + date[0..4].parse().ok()?, + date[5..7].parse().ok()?, + date[8..10].parse().ok()?, + )) +} + +fn parse_tz_offset(raw: &str) -> Option { + let (sign, rest) = if let Some(rest) = raw.strip_prefix('+') { + (1i64, rest) + } else if let Some(rest) = raw.strip_prefix('-') { + (-1, rest) + } else { + return None; + }; + let compact = rest.replace(':', ""); + if compact.len() < 2 { + return None; + } + let hours: i64 = compact[..2].parse().ok()?; + let minutes: i64 = if compact.len() >= 4 { + compact[2..4].parse().ok()? + } else { + 0 + }; + Some(sign * (hours * 3600 + minutes * 60)) +} + +pub fn civil_to_unix(year: i32, month: i32, day: i32) -> i64 { + let (mut year, mut month) = (year, month); + if month <= 2 { + year -= 1; + month += 9; + } else { + month -= 3; + } + let era = if year >= 0 { year } else { year - 399 } / 400; + let yoe = (year - era * 400) as u32; + let doy = (153 * month as u32 + 2) / 5 + day as u32 - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + (era as i64 * 146097 + doe as i64 - 719468) * 86400 +} + +/// Leftover CommitList buckets: `just now`, `Nm ago`, `Nh ago`, `Nd ago`, `Nmo ago`, `Ny ago`. +pub fn format_relative_time(iso: &str, now_secs: i64) -> String { + let Some(then) = parse_iso8601_secs(iso) else { + return iso.to_string(); + }; + let diff_sec = now_secs - then; + if diff_sec < 60 { + return "just now".into(); + } + let diff_min = diff_sec / 60; + if diff_min < 60 { + return format!("{diff_min}m ago"); + } + let diff_hr = diff_min / 60; + if diff_hr < 24 { + return format!("{diff_hr}h ago"); + } + let diff_day = diff_hr / 24; + if diff_day < 30 { + return format!("{diff_day}d ago"); + } + let diff_month = diff_day / 30; + if diff_month < 12 { + return format!("{diff_month}mo ago"); + } + format!("{}y ago", diff_month / 12) +} + +/// Leftover `getRelativeTimeValue` — signed, future-positive, no week bucket. +fn js_round(value: f64) -> i64 { + (value + 0.5).floor() as i64 +} + +pub fn relative_time_value(then_ms: i64, now_ms: i64) -> (i64, &'static str) { + let diff_seconds = js_round((then_ms - now_ms) as f64 / 1000.0); + let abs = diff_seconds.unsigned_abs(); + if abs < 60 { + (diff_seconds, "second") + } else if abs < 60 * 60 { + (js_round(diff_seconds as f64 / 60.0), "minute") + } else if abs < 60 * 60 * 24 { + (js_round(diff_seconds as f64 / (60.0 * 60.0)), "hour") + } else if abs < 60 * 60 * 24 * 30 { + (js_round(diff_seconds as f64 / (60.0 * 60.0 * 24.0)), "day") + } else if abs < 60 * 60 * 24 * 365 { + (js_round(diff_seconds as f64 / (60.0 * 60.0 * 24.0 * 30.0)), "month") + } else { + (js_round(diff_seconds as f64 / (60.0 * 60.0 * 24.0 * 365.0)), "year") + } +} + +fn format_medium_date(raw: &str, locale: Locale) -> Option { + let (year, month, day) = parse_ymd(raw)?; + if !(1..=12).contains(&month) { + return None; + } + Some(match locale { + Locale::En => format!("{} {}, {year}", EN_MONTHS[(month as usize) - 1], day), + Locale::Zh => format!("{year}年{month}月{day}日"), + }) +} + +fn format_long_relative(value: i64, unit: &str, locale: Locale) -> String { + if value == 0 { + return match locale { + Locale::En => "now".into(), + Locale::Zh => "现在".into(), + }; + } + match (locale, value, unit) { + (Locale::En, 1, "day") => "tomorrow".into(), + (Locale::En, -1, "day") => "yesterday".into(), + (Locale::En, 1, "month") => "next month".into(), + (Locale::En, -1, "month") => "last month".into(), + (Locale::En, 1, "year") => "next year".into(), + (Locale::En, -1, "year") => "last year".into(), + (Locale::Zh, 1, "day") => "明天".into(), + (Locale::Zh, -1, "day") => "昨天".into(), + (Locale::Zh, 1, "month") => "下个月".into(), + (Locale::Zh, -1, "month") => "上个月".into(), + (Locale::Zh, 1, "year") => "明年".into(), + (Locale::Zh, -1, "year") => "去年".into(), + (Locale::En, n, unit) => { + let abs = n.unsigned_abs(); + let word = if abs == 1 { unit.to_string() } else { format!("{unit}s") }; + if n > 0 { + format!("in {abs} {word}") + } else { + format!("{abs} {word} ago") + } + } + (Locale::Zh, n, unit) => { + let abs = n.unsigned_abs(); + let word = match unit { + "second" => "秒", + "minute" => "分钟", + "hour" => "小时", + "day" => "天", + "month" => "个月", + _ => "年", + }; + if n > 0 { + format!("{abs}{word}后") + } else { + format!("{abs}{word}前") + } + } + } +} + +/// Leftover About `formatReleaseDate`: medium date + long relative, e.g. `May 15, 2026 (in 2 months)`. +pub fn format_release_date_display(raw: &str, locale: Locale) -> String { + format_release_date_at(raw, locale, unix_now_ms()) +} + +pub fn format_release_date_at(raw: &str, locale: Locale, now_ms: i64) -> String { + if raw.trim().is_empty() { + return raw.to_string(); + } + let Some(then) = parse_iso8601_secs(raw) else { + return raw.to_string(); + }; + let absolute = format_medium_date(raw, locale).unwrap_or_else(|| raw.to_string()); + let (value, unit) = relative_time_value(then * 1000, now_ms); + format!("{absolute} ({})", format_long_relative(value, unit, locale)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_relative_time_matches_leftover_commit_list() { + let then = parse_iso8601_secs("2026-04-09T12:00:00Z").unwrap(); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 30), "just now"); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 5 * 60), "5m ago"); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 3 * 3600), "3h ago"); + assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 2 * 86400), "2d ago"); + assert_eq!( + format_relative_time("2026-04-09T12:00:00Z", then + 30 * 86400), + "1mo ago" + ); + assert_eq!( + format_relative_time("2026-04-09T12:00:00Z", then + 400 * 86400), + "1y ago" + ); + } + + #[test] + fn parse_iso8601_respects_timezone_offset() { + let utc = parse_iso8601_secs("2026-04-09T12:00:00Z").unwrap(); + let plus8 = parse_iso8601_secs("2026-04-09T20:00:00+08:00").unwrap(); + assert_eq!(utc, plus8); + assert_eq!(utc, civil_to_unix(2026, 4, 9) + 12 * 3600); + } + + #[test] + fn format_relative_time_falls_back_to_raw_date() { + assert_eq!(format_relative_time("not-a-date", 0), "not-a-date"); + } + + #[test] + fn leftover_release_date_uses_medium_and_long_relative() { + let now = parse_iso8601_secs("2026-05-15T00:00:00Z").unwrap() * 1000; + assert_eq!( + format_release_date_at("2026-05-15T00:00:00Z", Locale::En, now + 30_000), + "May 15, 2026 (30 seconds ago)" + ); + assert_eq!( + format_release_date_at("2026-05-15T00:00:00Z", Locale::En, now - 90_000), + "May 15, 2026 (in 2 minutes)" + ); + assert_eq!( + format_release_date_at("2026-07-15T00:00:00Z", Locale::En, now), + "Jul 15, 2026 (in 2 months)" + ); + assert_eq!( + format_release_date_at("2026-05-15T00:00:00Z", Locale::Zh, now + 90_000), + "2026年5月15日 (1分钟前)" + ); + assert_eq!(format_release_date_at("not-a-date", Locale::En, now), "not-a-date"); + } + + #[test] + fn leftover_relative_time_value_buckets() { + let now = parse_iso8601_secs("2026-05-15T00:00:00Z").unwrap() * 1000; + assert_eq!(relative_time_value(now + 30_000, now), (30, "second")); + assert_eq!(relative_time_value(now - 90_000, now), (-1, "minute")); + } +} diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index b05bd4f3..bef09847 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -963,6 +963,7 @@ enum MenuAction { ProjectTemplate(usize), NewTerm, Header, + DiscardGitFile, } struct MenuItem { @@ -1080,6 +1081,11 @@ fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec { app.t("deleteProfile"), MenuAction::DeleteProfile, ))], + ContextMenu::GitFile { .. } => vec![danger(item( + "discard-git-file", + app.t("gitDiscardFileAction"), + MenuAction::DiscardGitFile, + ))], ContextMenu::File { .. } => vec![ item("open", app.t("fileTreeContextMenuOpen"), MenuAction::Open), item( @@ -1214,6 +1220,9 @@ fn run_menu(app: &mut AppView, menu: &ContextMenu, action: MenuAction, window: & (ContextMenu::Profile { id, .. }, MenuAction::DeleteProfile) => { app.prepare_delete_profile(id); } + (ContextMenu::GitFile { path }, MenuAction::DiscardGitFile) => { + app.discard_file(path); + } (ContextMenu::File { path }, MenuAction::Open) => { if let Some(pid) = app.data.current_profile.clone() { if crate::app::git_status_kind( diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index 7abc1495..23ee5284 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -1,4 +1,4 @@ -use gpui::{div, img, prelude::*, px, rgb, Context, Window}; +use gpui::{div, img, prelude::*, px, Context, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::checkbox::Checkbox; use gpui_component::input::Input; @@ -8,9 +8,10 @@ use gpui_component::{Disableable, Selectable}; use crate::app::{extract_file_hunk, file_status_badge, AppView}; use crate::backend; use crate::diff::{self, DiffLineKind}; -use crate::state::{DialogKind, DiffPreviewMode, GitDiffTab}; +use crate::state::{ContextMenu, DialogKind, DiffPreviewMode, GitDiffTab}; +use crate::timefmt; -pub fn render_panel(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { +pub fn render_panel(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); let view = cx.entity(); let Some(ws) = app.data.current_ws() else { @@ -87,11 +88,9 @@ pub fn render_panel(app: &mut AppView, _window: &mut Window, cx: &mut Context (String, Option) { + match file_name.rfind('/') { + Some(idx) => (file_name[idx + 1..].to_string(), Some(file_name[..idx].to_string())), + None => (file_name.to_string(), None), + } +} + +pub fn leftover_change_badge(status: &str) -> (&'static str, &'static str) { + match file_status_badge(status) { + "A" => ("A", "green"), + "D" => ("D", "red"), + "R" => ("R", "amber"), + "M" => ("M", "blue"), + other => (other, "muted"), + } +} + +fn leftover_badge_color(kind: &str) -> gpui::Hsla { + match kind { + "green" => gpui::hsla(142. / 360., 0.70, 0.45, 1.), + "red" => gpui::hsla(0., 0.84, 0.60, 1.), + "blue" => gpui::hsla(217. / 360., 0.91, 0.60, 1.), + "amber" => gpui::hsla(38. / 360., 0.92, 0.50, 1.), + _ => gpui::hsla(0., 0., 0.55, 1.), + } +} + +fn leftover_status_badge(status: &str) -> impl IntoElement { + let (letter, kind) = leftover_change_badge(status); + let color = leftover_badge_color(kind); + h_flex() + .flex_none() + .w(px(16.)) + .h(px(16.)) + .rounded(px(2.)) + .border_1() + .border_color(color.opacity(0.3)) + .bg(color.opacity(0.1)) + .justify_center() + .child(div().font_family("monospace").text_xs().text_color(color).child(letter)) +} + +fn leftover_path_cell(path: &str, active: bool, muted: gpui::Hsla) -> impl IntoElement { + let (basename, parent) = file_display_parts(path); + h_flex() + .min_w_0() + .flex_1() + .gap_2() + .overflow_hidden() + .child( + div() + .min_w_0() + .flex_1() + .text_sm() + .overflow_hidden() + .when(active, |el| el.font_medium()) + .child(basename), + ) + .when_some(parent, |el, dir| { + el.child( + div() + .min_w(px(16.)) + .overflow_hidden() + .text_xs() + .text_color(muted) + .child(dir), + ) + }) +} + fn file_row( app: &AppView, path: &str, status: &str, checked: bool, compact: bool, + _window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let view = cx.entity(); let theme = cx.theme().clone(); - let badge = file_status_badge(status); - let name = crate::backend::file_name(path); let path_owned = path.to_string(); + let status_owned = status.to_string(); + let active = app.data.overlay.git_diff_file.as_deref() == Some(path); h_flex() .id(crate::ui::eid(format!("git-file-{path}"))) .w_full() - .px_2() - .py_1() + .min_w_0() + .items_start() + .px_3() + .py_2() .gap_2() + .overflow_hidden() + .when(active, |el| el.bg(theme.muted)) + .when(!checked, |el| el.opacity(0.7)) .hover(|el| el.bg(theme.muted)) .on_click({ let view = view.clone(); let path = path_owned.clone(); - move |_, _, cx| { + let status = status_owned.clone(); + move |ev, window, cx| { + view.update(cx, |app, cx| { + app.select_diff_file(&path); + if ev.click_count() >= 2 { + if compact { + app.open_git_diff(); + } else if let Some(pid) = app.data.current_profile.clone() { + if crate::app::git_status_kind(&status) != crate::app::GitStatusKind::Deleted { + app.open_file(&pid, &path, window, cx); + } + app.data.overlay.git_diff_open = false; + } + } + cx.notify(); + }); + } + }) + .on_mouse_down(MouseButton::Right, { + let view = view.clone(); + let path = path_owned.clone(); + move |ev, _, cx| { view.update(cx, |app, cx| { app.select_diff_file(&path); - app.data.overlay.git_diff_open = true; + app.data.overlay.context_menu = Some(( + ContextMenu::GitFile { path: path.clone() }, + f32::from(ev.position.x), + f32::from(ev.position.y), + )); cx.notify(); }); } @@ -150,38 +250,8 @@ fn file_row( } }), ) - .child(crate::ui::file_icons::file_glyph(path, false, false, 13.)) - .child(div().flex_1().text_sm().child(name)) - .child( - div() - .text_xs() - .text_color(match badge { - "A" => theme.success, - "D" => theme.danger, - "R" | "M" => theme.warning, - _ => theme.muted_foreground, - }) - .child(badge), - ) - .when(compact, |el| { - el.child( - Button::new(crate::ui::eid(format!("discard-{path}"))) - .ghost() - .xsmall() - .icon(IconName::Undo) - .tooltip(app.t("gitDiscardFileAction")) - .on_click({ - let view = view.clone(); - let path = path_owned.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.discard_file(&path); - cx.notify(); - }); - } - }), - ) - }) + .child(leftover_path_cell(path, active, theme.muted_foreground)) + .child(leftover_status_badge(status)) } fn commit_composer( @@ -312,7 +382,7 @@ fn commit_composer( ) } -pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { +pub fn render_diff_dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !app.data.overlay.git_diff_open { return div().id("git-diff-closed").into_any_element(); } @@ -491,6 +561,7 @@ pub fn render_diff_dialog(app: &mut AppView, _window: &mut Window, cx: &mut Cont .map(|w| w.git_included.contains(p)) .unwrap_or(false), false, + window, cx, ) })) @@ -609,100 +680,6 @@ fn tab_btn( }) } -pub fn format_relative_time(iso: &str, now_secs: i64) -> String { - let Some(then) = parse_iso8601_secs(iso) else { - return iso.to_string(); - }; - let diff_sec = now_secs - then; - if diff_sec < 60 { - return "just now".into(); - } - let diff_min = diff_sec / 60; - if diff_min < 60 { - return format!("{diff_min}m ago"); - } - let diff_hr = diff_min / 60; - if diff_hr < 24 { - return format!("{diff_hr}h ago"); - } - let diff_day = diff_hr / 24; - if diff_day < 30 { - return format!("{diff_day}d ago"); - } - let diff_month = diff_day / 30; - if diff_month < 12 { - return format!("{diff_month}mo ago"); - } - format!("{}y ago", diff_month / 12) -} - -fn parse_iso8601_secs(raw: &str) -> Option { - let s = raw.trim(); - let (date, time_tz) = s.split_once('T')?; - let mut dp = date.split('-'); - let year: i32 = dp.next()?.parse().ok()?; - let month: i32 = dp.next()?.parse().ok()?; - let day: i32 = dp.next()?.parse().ok()?; - let (time, offset) = if let Some(time) = time_tz.strip_suffix('Z').or_else(|| time_tz.strip_suffix('z')) { - (time, 0i64) - } else if let Some(idx) = time_tz.rfind('+') { - (&time_tz[..idx], parse_tz_offset(&time_tz[idx..])?) - } else if let Some(rel) = time_tz.find('-').filter(|&i| i >= 8) { - (&time_tz[..rel], parse_tz_offset(&time_tz[rel..])?) - } else { - (time_tz, 0) - }; - let time = time.split('.').next()?; - let mut tp = time.split(':'); - let hour: i64 = tp.next()?.parse().ok()?; - let minute: i64 = tp.next()?.parse().ok()?; - let second: i64 = tp.next().unwrap_or("0").parse().ok()?; - Some(civil_to_unix(year, month, day) + hour * 3600 + minute * 60 + second - offset) -} - -fn parse_tz_offset(raw: &str) -> Option { - let (sign, rest) = if let Some(rest) = raw.strip_prefix('+') { - (1i64, rest) - } else if let Some(rest) = raw.strip_prefix('-') { - (-1, rest) - } else { - return None; - }; - let compact = rest.replace(':', ""); - if compact.len() < 2 { - return None; - } - let hours: i64 = compact[..2].parse().ok()?; - let minutes: i64 = if compact.len() >= 4 { - compact[2..4].parse().ok()? - } else { - 0 - }; - Some(sign * (hours * 3600 + minutes * 60)) -} - -fn civil_to_unix(year: i32, month: i32, day: i32) -> i64 { - let (mut year, mut month) = (year, month); - if month <= 2 { - year -= 1; - month += 9; - } else { - month -= 3; - } - let era = if year >= 0 { year } else { year - 399 } / 400; - let yoe = (year - era * 400) as u32; - let doy = (153 * month as u32 + 2) / 5 + day as u32 - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - (era as i64 * 146097 + doe as i64 - 719468) * 86400 -} - -fn unix_now_secs() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); let theme = cx.theme().clone(); @@ -781,15 +758,15 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { .children(app.data.overlay.git_commit_files.iter().map(|p| { let path = p.clone(); let active = selected_file.as_deref() == Some(p.as_str()); - let (name, parent) = match p.rsplit_once('/') { - Some((dir, base)) => (base.to_string(), Some(dir.to_string())), - None => (p.clone(), None), - }; h_flex() .id(crate::ui::eid(format!("cfile-{p}"))) + .w_full() + .min_w_0() + .items_start() .px_3() .py_2() .gap_2() + .overflow_hidden() .when(active, |el| el.bg(theme.muted)) .hover(|el| el.bg(theme.muted)) .on_click({ @@ -801,10 +778,8 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { }); } }) - .child(div().text_sm().when(active, |el| el.font_medium()).child(name)) - .when_some(parent, |el, dir| { - el.child(div().text_xs().text_color(theme.muted_foreground).child(dir)) - }) + .child(leftover_path_cell(p, active, theme.muted_foreground)) + .child(leftover_status_badge("M")) })) .into_any_element() }) @@ -818,7 +793,7 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { .child(app.t("noCommitsFound")) .into_any_element(); } - let now = unix_now_secs(); + let now = timefmt::unix_now_secs(); v_flex() .id("commit-list") .children(app.data.overlay.git_commits.iter().map(|c| { @@ -847,7 +822,7 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { .text_color(theme.muted_foreground) .child(div().font_family("monospace").child(hash.clone())) .child(div().min_w_0().flex_1().child(c.author.name.clone())) - .child(div().child(format_relative_time(&c.date, now))), + .child(div().child(timefmt::format_relative_time(&c.date, now))), ) .child( h_flex() @@ -1211,35 +1186,24 @@ fn image_preview(app: &AppView, path: &str, diff: &str) -> impl IntoElement { #[cfg(test)] mod tests { - use super::{civil_to_unix, format_relative_time, parse_iso8601_secs}; + use super::{file_display_parts, leftover_change_badge}; #[test] - fn format_relative_time_matches_leftover_commit_list() { - let then = parse_iso8601_secs("2026-04-09T12:00:00Z").unwrap(); - assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 30), "just now"); - assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 5 * 60), "5m ago"); - assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 3 * 3600), "3h ago"); - assert_eq!(format_relative_time("2026-04-09T12:00:00Z", then + 2 * 86400), "2d ago"); + fn leftover_file_display_parts_split_git_paths() { assert_eq!( - format_relative_time("2026-04-09T12:00:00Z", then + 30 * 86400), - "1mo ago" + file_display_parts("src/features/git/FileListItem.tsx"), + ("FileListItem.tsx".into(), Some("src/features/git".into())) ); - assert_eq!( - format_relative_time("2026-04-09T12:00:00Z", then + 400 * 86400), - "1y ago" - ); - } - - #[test] - fn parse_iso8601_respects_timezone_offset() { - let utc = parse_iso8601_secs("2026-04-09T12:00:00Z").unwrap(); - let plus8 = parse_iso8601_secs("2026-04-09T20:00:00+08:00").unwrap(); - assert_eq!(utc, plus8); - assert_eq!(utc, civil_to_unix(2026, 4, 9) + 12 * 3600); + assert_eq!(file_display_parts("README.md"), ("README.md".into(), None)); } #[test] - fn format_relative_time_falls_back_to_raw_date() { - assert_eq!(format_relative_time("not-a-date", 0), "not-a-date"); + fn leftover_change_badge_matches_file_list_item() { + assert_eq!(leftover_change_badge("added"), ("A", "green")); + assert_eq!(leftover_change_badge("deleted"), ("D", "red")); + assert_eq!(leftover_change_badge("change"), ("M", "blue")); + assert_eq!(leftover_change_badge("renamed"), ("R", "amber")); + assert_eq!(leftover_change_badge("A"), ("A", "green")); + assert_eq!(leftover_change_badge("M"), ("M", "blue")); } } diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 0d2fdb2d..92c82c63 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -1182,13 +1182,18 @@ impl SettingsView { .child(link_btn("rel", self.t("releases"), "https://github.com/AkaraChen/2code/releases")) .child(link_btn("web", self.t("website"), "https://github.com/AkaraChen/2code")), ) + .child(div().font_semibold().child(self.t("update"))) .child(div().text_sm().child(self.update_status.clone())) .child( if let Some(date) = &self.released_at { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child(crate::i18n::tf(self.locale, "updateReleasedAt", &[("date", date)])) + .child(crate::i18n::tf( + self.locale, + "updateReleasedAt", + &[("date", &crate::updater::format_release_date_display(date, self.locale))], + )) .into_any_element() } else { div().into_any_element() diff --git a/src-gpui/src/updater.rs b/src-gpui/src/updater.rs index 905560fc..f7da1986 100644 --- a/src-gpui/src/updater.rs +++ b/src-gpui/src/updater.rs @@ -85,17 +85,12 @@ pub fn check_for_update(accept_beta: bool) -> Result { } else { release.html_url.clone() }, - released_at: release.published_at.as_deref().and_then(format_release_date), + released_at: release.published_at.clone(), }) } -fn format_release_date(raw: &str) -> Option { - let date = raw.get(..10)?; - if date.as_bytes().get(4) == Some(&b'-') && date.as_bytes().get(7) == Some(&b'-') { - Some(date.to_string()) - } else { - None - } +pub fn format_release_date_display(raw: &str, locale: crate::i18n::Locale) -> String { + crate::timefmt::format_release_date_display(raw, locale) } fn pick_release(releases: &[GithubRelease], accept_beta: bool) -> Option<&GithubRelease> { @@ -352,10 +347,17 @@ mod tests { #[test] fn formats_github_release_date() { assert_eq!( - format_release_date("2026-04-09T12:00:00Z").as_deref(), - Some("2026-04-09") + crate::timefmt::format_release_date_at( + "2026-04-09T12:00:00Z", + crate::i18n::Locale::En, + crate::timefmt::parse_iso8601_secs("2026-04-09T12:00:00Z").unwrap() * 1000, + ), + "Apr 9, 2026 (now)" + ); + assert_eq!( + crate::timefmt::format_release_date_at("not-a-date", crate::i18n::Locale::En, 0), + "not-a-date" ); - assert_eq!(format_release_date("not-a-date"), None); } #[test] From 258bb00684cba76257f6e2c710feb077ee0f9915 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:30:58 +0000 Subject: [PATCH 36/50] Port leftover git-diff keyboard nav and search-only command palette. The large git overlay now steps files and commits like the leftover reducer, Space toggles include, Enter drills into a commit, and Escape/Backspace leave drill-in instead of closing the dialog. Cmd/Ctrl+Enter pushes when there are no local changes but the branch is ahead. The command palette matches leftover cmdk: search row only, stacked name/parent results, and leftover empty copy. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 170 ++++++++++++++++++++++++++++++++++++- src-gpui/src/state.rs | 3 + src-gpui/src/ui/git.rs | 53 ++++++++++-- src-gpui/src/ui/palette.rs | 93 +++++++------------- 4 files changed, 247 insertions(+), 72 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index fd7462ca..80773549 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -4,7 +4,7 @@ use std::time::Duration; use gpui::{ div, prelude::*, px, Action, App, ClipboardEntry, ClipboardItem, Context, Entity, EntityInputHandler, FocusHandle, - KeyBinding, Timer, Window, WindowHandle, + Focusable, KeyBinding, Timer, Window, WindowHandle, }; #[derive(Clone, PartialEq, Default, Debug, Action)] @@ -1808,13 +1808,59 @@ impl AppView { self.data.overlay.git_diff_tab = GitDiffTab::Changes; self.data.overlay.git_diff_text = self.backend.git_diff(&profile_id).unwrap_or_default(); self.data.overlay.git_commits = self.backend.git_log(&profile_id, 80).unwrap_or_default(); + self.data.overlay.git_selected_commit = None; + self.data.overlay.git_commit_files.clear(); + self.data.overlay.git_file_index = 0; + self.data.overlay.git_commit_index = None; + self.data.overlay.git_commit_file_index = 0; self.refresh_workspace(&profile_id); + if let Some(path) = self + .data + .current_ws() + .and_then(|ws| ws.git_files.first().map(|(p, _)| p.clone())) + { + self.select_diff_file(&path); + } + } + + pub fn switch_git_tab(&mut self, tab: GitDiffTab) { + self.data.overlay.git_diff_tab = tab; + self.data.overlay.git_selected_commit = None; + self.data.overlay.git_commit_files.clear(); + self.data.overlay.git_commit_file_index = 0; + self.data.overlay.git_commit_index = None; + if tab == GitDiffTab::Changes { + self.data.overlay.git_file_index = 0; + if let Some(path) = self + .data + .current_ws() + .and_then(|ws| ws.git_files.first().map(|(p, _)| p.clone())) + { + self.select_diff_file(&path); + } + } + } + + pub fn commit_back(&mut self) { + self.data.overlay.git_selected_commit = None; + self.data.overlay.git_commit_files.clear(); + self.data.overlay.git_commit_file_index = 0; + self.data.overlay.git_diff_file = None; } pub fn select_diff_file(&mut self, path: &str) { self.data.overlay.git_diff_file = Some(path.to_string()); + if let Some(ix) = self + .data + .current_ws() + .and_then(|ws| ws.git_files.iter().position(|(p, _)| p == path)) + { + self.data.overlay.git_file_index = ix; + } if let Some(profile_id) = self.data.current_profile.clone() { - self.data.overlay.git_diff_text = self.backend.git_diff(&profile_id).unwrap_or_default(); + if self.data.overlay.git_selected_commit.is_none() { + self.data.overlay.git_diff_text = self.backend.git_diff(&profile_id).unwrap_or_default(); + } } } @@ -1929,10 +1975,18 @@ impl AppView { pub fn select_commit(&mut self, hash: &str) { self.data.overlay.git_selected_commit = Some(hash.to_string()); + self.data.overlay.git_commit_index = self + .data + .overlay + .git_commits + .iter() + .position(|c| c.hash == hash || c.full_hash == hash); + self.data.overlay.git_commit_file_index = 0; if let Some(profile_id) = self.data.current_profile.clone() { let diff = self.backend.commit_diff(&profile_id, hash).unwrap_or_default(); self.data.overlay.git_commit_files = parse_diff_files(&diff); self.data.overlay.git_diff_text = diff; + self.data.overlay.git_diff_file = self.data.overlay.git_commit_files.first().cloned(); } } @@ -2636,6 +2690,10 @@ impl AppView { } } if self.data.overlay.git_diff_open { + if self.data.overlay.git_selected_commit.is_some() { + self.commit_back(); + return true; + } self.data.overlay.git_diff_open = false; return true; } @@ -2730,6 +2788,100 @@ impl AppView { true } + fn leftover_interactive_focused(&self, window: &Window, cx: &App) -> bool { + [ + &self.inputs.commit_summary, + &self.inputs.commit_body, + &self.inputs.review_comment, + &self.inputs.palette, + &self.inputs.file_search, + &self.inputs.branch_search, + &self.inputs.debug_search, + &self.inputs.term_search, + &self.inputs.file_editor, + &self.inputs.notes, + &self.inputs.md_link, + &self.inputs.rename, + &self.inputs.new_path, + ] + .iter() + .any(|input| input.read(cx).focus_handle(cx).is_focused(window)) + } + + pub fn handle_git_list_key(&mut self, key: &str) -> bool { + if !self.data.overlay.git_diff_open { + return false; + } + match (self.data.overlay.git_diff_tab, key) { + (GitDiffTab::Changes, "up" | "down") => { + let count = self.data.current_ws().map(|ws| ws.git_files.len()).unwrap_or(0); + let next = crate::ui::git::leftover_step_index( + self.data.overlay.git_file_index, + if key == "down" { 1 } else { -1 }, + count, + ); + self.data.overlay.git_file_index = next; + if let Some(path) = self + .data + .current_ws() + .and_then(|ws| ws.git_files.get(next).map(|(p, _)| p.clone())) + { + self.select_diff_file(&path); + } + true + } + (GitDiffTab::Changes, "space") => { + let path = self.data.current_ws().and_then(|ws| { + ws.git_files + .get(self.data.overlay.git_file_index) + .map(|(p, _)| p.clone()) + }); + let Some(path) = path else { + return false; + }; + if let Some(ws) = self.data.current_ws_mut() { + if !ws.git_included.remove(&path) { + ws.git_included.insert(path); + } + } + true + } + (GitDiffTab::History, "up" | "down") if self.data.overlay.git_selected_commit.is_some() => { + let count = self.data.overlay.git_commit_files.len(); + let next = crate::ui::git::leftover_step_index( + self.data.overlay.git_commit_file_index, + if key == "down" { 1 } else { -1 }, + count, + ); + self.data.overlay.git_commit_file_index = next; + self.data.overlay.git_diff_file = self.data.overlay.git_commit_files.get(next).cloned(); + true + } + (GitDiffTab::History, "up" | "down") => { + self.data.overlay.git_commit_index = crate::ui::git::leftover_step_commit_index( + self.data.overlay.git_commit_index, + if key == "down" { 1 } else { -1 }, + self.data.overlay.git_commits.len(), + ); + true + } + (GitDiffTab::History, "enter") if self.data.overlay.git_selected_commit.is_none() => { + if let Some(ix) = self.data.overlay.git_commit_index { + if let Some(hash) = self.data.overlay.git_commits.get(ix).map(|c| c.hash.clone()) { + self.select_commit(&hash); + return true; + } + } + false + } + (GitDiffTab::History, "backspace") if self.data.overlay.git_selected_commit.is_some() => { + self.commit_back(); + true + } + _ => false, + } + } + pub fn handle_overlay_key(&mut self, key: &str, shift: bool, window: &mut Window, cx: &mut Context) -> bool { if self.data.overlay.dialog == Some(DialogKind::CreateProject) && key == "enter" { self.create_project_from_dialog(window, cx); @@ -2794,6 +2946,9 @@ impl AppView { self.commit_rename_path(cx); return true; } + if self.data.overlay.git_diff_open && !self.leftover_interactive_focused(window, cx) { + return self.handle_git_list_key(key); + } false } @@ -3054,7 +3209,16 @@ impl gpui::Render for AppView { cx.notify(); })) .on_action(cx.listener(|this, _: &CommitChanges, _, cx| { - this.commit_selected(cx); + let (no_files, ahead) = this + .data + .current_ws() + .map(|ws| (ws.git_files.is_empty(), ws.git_ahead)) + .unwrap_or((true, 0)); + if no_files && ahead > 0 { + this.push_current(); + } else { + this.commit_selected(cx); + } cx.notify(); })) .on_action(cx.listener(|this, _: &DismissOverlay, _, cx| { diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 5a100aa8..c539f428 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -601,6 +601,9 @@ pub struct OverlayState { pub git_commits: Vec, pub git_commit_files: Vec, pub git_selected_commit: Option, + pub git_file_index: usize, + pub git_commit_index: Option, + pub git_commit_file_index: usize, pub git_large_revealed: HashSet, pub review_comments: Vec, pub review_selection: Option, diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index 23ee5284..d524884f 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -105,6 +105,24 @@ pub fn file_display_parts(file_name: &str) -> (String, Option) { } } +pub fn leftover_step_index(current: usize, delta: i32, count: usize) -> usize { + if count == 0 { + return current; + } + let next = current as i32 + delta; + next.clamp(0, count as i32 - 1) as usize +} + +pub fn leftover_step_commit_index(current: Option, delta: i32, count: usize) -> Option { + if count == 0 { + return current; + } + Some(match current { + None => 0, + Some(index) => leftover_step_index(index, delta, count), + }) +} + pub fn leftover_change_badge(status: &str) -> (&'static str, &'static str) { match file_status_badge(status) { "A" => ("A", "green"), @@ -674,7 +692,7 @@ fn tab_btn( .label(label) .on_click(move |_, _, cx| { view.update(cx, |app, cx| { - app.data.overlay.git_diff_tab = tab; + app.switch_git_tab(tab); cx.notify(); }); }) @@ -755,9 +773,10 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { &[("count", &app.data.overlay.git_commit_files.len().to_string())], )), ) - .children(app.data.overlay.git_commit_files.iter().map(|p| { + .children(app.data.overlay.git_commit_files.iter().enumerate().map(|(ix, p)| { let path = p.clone(); - let active = selected_file.as_deref() == Some(p.as_str()); + let active = app.data.overlay.git_commit_file_index == ix + || selected_file.as_deref() == Some(p.as_str()); h_flex() .id(crate::ui::eid(format!("cfile-{p}"))) .w_full() @@ -773,6 +792,7 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { let view = view.clone(); move |_, _, cx| { view.update(cx, |app, cx| { + app.data.overlay.git_commit_file_index = ix; app.data.overlay.git_diff_file = Some(path.clone()); cx.notify(); }); @@ -794,15 +814,18 @@ fn history_pane(app: &AppView, cx: &mut Context) -> impl IntoElement { .into_any_element(); } let now = timefmt::unix_now_secs(); + let selected_ix = app.data.overlay.git_commit_index; v_flex() .id("commit-list") - .children(app.data.overlay.git_commits.iter().map(|c| { + .children(app.data.overlay.git_commits.iter().enumerate().map(|(ix, c)| { let hash = c.hash.clone(); + let active = selected_ix == Some(ix); v_flex() .id(crate::ui::eid(format!("commit-{hash}"))) .px_3() .py(px(6.)) .gap(px(2.)) + .when(active, |el| el.bg(theme.muted)) .hover(|el| el.bg(theme.muted)) .on_click({ let view = view.clone(); @@ -1186,7 +1209,7 @@ fn image_preview(app: &AppView, path: &str, diff: &str) -> impl IntoElement { #[cfg(test)] mod tests { - use super::{file_display_parts, leftover_change_badge}; + use super::{file_display_parts, leftover_change_badge, leftover_step_commit_index, leftover_step_index}; #[test] fn leftover_file_display_parts_split_git_paths() { @@ -1197,6 +1220,26 @@ mod tests { assert_eq!(file_display_parts("README.md"), ("README.md".into(), None)); } + #[test] + fn leftover_step_index_clamps_like_git_diff_reducer() { + assert_eq!(leftover_step_index(2, 1, 10), 3); + assert_eq!(leftover_step_index(3, -1, 10), 2); + assert_eq!(leftover_step_index(0, -1, 5), 0); + assert_eq!(leftover_step_index(4, 1, 5), 4); + assert_eq!(leftover_step_index(0, 1, 1), 0); + assert_eq!(leftover_step_index(0, 1, 0), 0); + assert_eq!(leftover_step_index(2, 100, 5), 4); + } + + #[test] + fn leftover_step_commit_index_starts_at_first() { + assert_eq!(leftover_step_commit_index(None, 1, 5), Some(0)); + assert_eq!(leftover_step_commit_index(None, -1, 5), Some(0)); + assert_eq!(leftover_step_commit_index(None, 1, 0), None); + assert_eq!(leftover_step_commit_index(Some(1), 1, 5), Some(2)); + assert_eq!(leftover_step_commit_index(Some(4), 1, 5), Some(4)); + } + #[test] fn leftover_change_badge_matches_file_list_item() { assert_eq!(leftover_change_badge("added"), ("A", "green")); diff --git a/src-gpui/src/ui/palette.rs b/src-gpui/src/ui/palette.rs index a7466f7a..1c64a240 100644 --- a/src-gpui/src/ui/palette.rs +++ b/src-gpui/src/ui/palette.rs @@ -67,59 +67,42 @@ pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context } }) .child( - v_flex() + div() .px_4() .py_3() - .gap_1() .border_b_1() .border_color(theme.border) - .child(div().text_xs().font_semibold().child(app.t("commandPaletteTitle"))) .child(Input::new(&app.inputs.palette)), ) - .child(if q.is_empty() { - v_flex() - .p_4() - .gap_1() + .child(if results.is_empty() { + div() + .flex() + .justify_center() + .px_4() + .py_8() .child( div() .text_sm() .text_color(theme.muted_foreground) - .child(app.t("commandPaletteEmpty")), - ) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(app.t("commandPaletteHint")), - ) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(app.t("commandPaletteOpenHint")), - ) - .into_any_element() - } else if results.is_empty() { - v_flex() - .p_4() - .gap_1() - .child(div().child(app.t("commandPaletteNoResults"))) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(app.t("commandPaletteNoResultsHint")), + .child(if q.trim().is_empty() { + app.t("commandPaletteEmpty") + } else { + app.t("commandPaletteNoResults") + }), ) .into_any_element() } else { v_flex() - .max_h(px(360.)) + .max_h(px(420.)) + .p_1() .children(results.iter().enumerate().map(|(ix, r)| { h_flex() .id(crate::ui::eid(format!("pal-{ix}"))) + .min_w_0() .px_3() .py_2() .gap_2() + .rounded_md() .when(ix == selected, |el| el.bg(theme.muted)) .on_click({ let view = view.clone(); @@ -131,41 +114,23 @@ pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context }); } }) - .child(crate::ui::file_icons::file_glyph(&r.path, false, false, 16.)) - .child(div().text_sm().child(r.name.clone())) + .child(crate::ui::file_icons::file_glyph(&r.name, false, false, 16.)) .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(parent_label(&r.relative_path, &app.t("commandPaletteRoot"))), + v_flex() + .min_w_0() + .flex_1() + .child(div().text_sm().overflow_hidden().child(r.name.clone())) + .child( + div() + .text_xs() + .overflow_hidden() + .text_color(theme.muted_foreground) + .child(parent_label(&r.relative_path, &app.t("commandPaletteRoot"))), + ), ) })) .into_any_element() - }) - .child( - h_flex() - .px_3() - .py_2() - .border_t_1() - .border_color(theme.border) - .justify_between() - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(crate::i18n::tf( - app.data.locale, - "commandPaletteResultCount", - &[("count", &results.len().to_string())], - )), - ) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(app.t("commandPaletteFooterHint")), - ), - ), + }), ) .into_any_element() } From 9f3d0c105c73773e95cfb79949780f4494bb7b32 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:33:24 +0000 Subject: [PATCH 37/50] Match leftover Switch Branch rows and GitDiff chrome. Switch Branch is leftover max-width, search-only (no footer), branch glyph, current/trunk/used chips, and green/red ahead-behind counts only on non-current rows. The large diff overlay uses leftover 88rem x 56rem sizing and a truncated branch title with a Close tooltip. Co-authored-by: AkaraChen --- src-gpui/src/ui/dialogs.rs | 192 +++++++++++++++++++++++++------------ src-gpui/src/ui/git.rs | 15 +-- src-gpui/src/ui/mod.rs | 4 + 3 files changed, 145 insertions(+), 66 deletions(-) diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index bef09847..495fd268 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -63,10 +63,9 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> .id("dialog-panel") .w(px(if matches!(kind, DialogKind::ReviewQueue) { 720. - } else if matches!( - kind, - DialogKind::ProjectSettings | DialogKind::SwitchBranch | DialogKind::DebugLog - ) { + } else if matches!(kind, DialogKind::SwitchBranch) { + 448. + } else if matches!(kind, DialogKind::ProjectSettings | DialogKind::DebugLog) { 560. } else { 380. @@ -100,6 +99,9 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> .when(kind == DialogKind::ReviewQueue, |el| { el.child(Icon::new(IconName::Inbox).w(px(18.))) }) + .when(kind == DialogKind::SwitchBranch, |el| { + el.child(crate::ui::leftover_branch_glyph(theme.muted_foreground)) + }) .child(div().font_semibold().child(title)), ) .child(Button::new("dlg-x").ghost().xsmall().icon(IconName::Close).on_click({ @@ -444,51 +446,21 @@ fn dialog_body( .gap_2() .child(Input::new(&app.inputs.branch_search)) .child(if branches.is_empty() { - div().p_3().child(app.t("noBranchesFound")).into_any_element() + div() + .py_8() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noBranchesFound")) + .into_any_element() } else { v_flex() - .max_h(px(320.)) - .children(branches.into_iter().map(|b| { - let name = b.name.clone(); - let disabled = b.is_current || b.is_used; - h_flex() - .id(crate::ui::eid(format!("br-{name}"))) - .px_2() - .py_1() - .gap_2() - .rounded_md() - .when(b.is_current, |el| el.bg(theme.muted)) - .hover(|el| el.bg(theme.muted)) - .on_click({ - let view = view.clone(); - let name = name.clone(); - move |_, _, cx| { - if disabled { - return; - } - view.update(cx, |app, cx| { - app.checkout_branch(&name); - cx.notify(); - }); - } - }) - .child(div().flex_1().child(name)) - .when(b.is_current, |el| { - el.child(badge(&app.t("branchCurrentLabel"), theme.muted_foreground)) - }) - .when(b.is_trunk, |el| { - el.child(badge(&app.t("branchTrunkLabel"), theme.muted_foreground)) - }) - .when(b.is_used, |el| { - el.child(badge(&app.t("branchUsedLabel"), theme.warning)) - }) - .when(b.ahead > 0, |el| { - el.child(div().text_xs().child(format!("↑{}", b.ahead))) - }) - .when(b.behind > 0, |el| { - el.child(div().text_xs().child(format!("↓{}", b.behind))) - }) - })) + .max_h(px(384.)) + .gap_0() + .children( + branches + .into_iter() + .map(|b| leftover_branch_row(app, &b, theme.muted, theme.muted_foreground, &view)), + ) .into_any_element() }) .into_any_element() @@ -709,8 +681,105 @@ fn review_queue_body(app: &AppView, muted: gpui::Hsla, cx: &mut Context }) } -fn badge(text: &str, color: gpui::Hsla) -> impl IntoElement { - div().px_1().text_xs().rounded_md().bg(color).child(text.to_string()) +fn leftover_chip(text: &str, fg: gpui::Hsla, bg: gpui::Hsla, border: gpui::Hsla) -> impl IntoElement { + div() + .h(px(16.)) + .px(px(6.)) + .rounded_sm() + .border_1() + .border_color(border) + .bg(bg) + .text_color(fg) + .text_xs() + .child(text.to_string()) +} + +fn leftover_branch_row( + app: &AppView, + branch: &model::project::GitBranchInfo, + muted: gpui::Hsla, + muted_fg: gpui::Hsla, + view: &gpui::Entity, +) -> impl IntoElement { + let name = branch.name.clone(); + let disabled = branch.is_current || branch.is_used; + let amber = gpui::hsla(38. / 360., 0.92, 0.50, 1.); + h_flex() + .id(crate::ui::eid(format!("br-{name}"))) + .w_full() + .min_w_0() + .px_3() + .py_2() + .gap_2() + .rounded_md() + .when(branch.is_current, |el| el.bg(muted)) + .when(disabled && !branch.is_current, |el| el.opacity(0.6)) + .hover(|el| el.bg(muted)) + .on_click({ + let view = view.clone(); + let name = name.clone(); + move |_, _, cx| { + if disabled { + return; + } + view.update(cx, |app, cx| { + app.checkout_branch(&name); + cx.notify(); + }); + } + }) + .child(crate::ui::leftover_branch_glyph(muted_fg)) + .child( + div() + .min_w_0() + .flex_1() + .text_sm() + .overflow_hidden() + .when(branch.is_current, |el| el.font_medium()) + .child(name), + ) + .when(branch.is_current, |el| { + el.child(leftover_chip(&app.t("branchCurrentLabel"), muted_fg, muted, muted)) + }) + .when(branch.is_trunk, |el| { + el.child(leftover_chip( + &app.t("branchTrunkLabel"), + muted_fg, + gpui::hsla(0., 0., 0., 0.), + muted_fg.opacity(0.3), + )) + }) + .when(branch.is_used, |el| { + el.child(leftover_chip( + &app.t("branchUsedLabel"), + amber, + amber.opacity(0.1), + amber.opacity(0.3), + )) + }) + .when(!branch.is_current && (branch.ahead > 0 || branch.behind > 0), |el| { + el.child( + h_flex() + .flex_none() + .gap_1() + .font_family("monospace") + .text_xs() + .when(branch.ahead > 0, |row| { + row.child( + div() + .text_color(gpui::hsla(0.38, 0.7, 0.45, 1.)) + .child(format!("↑{}", branch.ahead)), + ) + }) + .when(branch.behind > 0, |row| { + row.child( + div() + .text_color(gpui::hsla(0.02, 0.75, 0.5, 1.)) + .child(format!("↓{}", branch.behind)), + ) + }), + ) + }) } fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> impl IntoElement { @@ -747,17 +816,20 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> h_flex() .justify_end() .gap_2() - .when(kind != DialogKind::ReviewQueue, |el| { - el.child(Button::new("dlg-cancel").small().label(app.t("cancel")).on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.dialog = None; - cx.notify(); - }); - } - })) - }) + .when( + kind != DialogKind::ReviewQueue && kind != DialogKind::SwitchBranch, + |el| { + el.child(Button::new("dlg-cancel").small().label(app.t("cancel")).on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + cx.notify(); + }); + } + })) + }, + ) .when(kind == DialogKind::ReviewQueue, |el| { el.child( Button::new("dlg-copy") diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index d524884f..c24b1e74 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -2,7 +2,7 @@ use gpui::{div, img, prelude::*, px, Context, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::checkbox::Checkbox; use gpui_component::input::Input; -use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; use gpui_component::{Disableable, Selectable}; use crate::app::{extract_file_hunk, file_status_badge, AppView}; @@ -451,8 +451,8 @@ pub fn render_diff_dialog(app: &mut AppView, window: &mut Window, cx: &mut Conte .child( v_flex() .id("git-diff-dialog") - .w(px(1100.)) - .h(px(720.)) + .w(px(1408.)) + .h(px(896.)) .bg(theme.background) .rounded_xl() .border_1() @@ -469,9 +469,11 @@ pub fn render_diff_dialog(app: &mut AppView, window: &mut Window, cx: &mut Conte .justify_between() .child( h_flex() - .gap_2() - .child(Icon::new(IconName::GitHub).w(px(16.))) - .child(div().font_semibold().child(branch)), + .min_w_0() + .flex_1() + .gap(px(6.)) + .child(crate::ui::leftover_branch_glyph(theme.muted_foreground)) + .child(div().min_w_0().flex_1().text_sm().overflow_hidden().child(branch)), ) .child( h_flex() @@ -517,6 +519,7 @@ pub fn render_diff_dialog(app: &mut AppView, window: &mut Window, cx: &mut Conte .ghost() .xsmall() .icon(IconName::Close) + .tooltip("Close".to_string()) .on_click({ let view = view.clone(); move |_, _, cx| { diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs index 98abb976..d4913f36 100644 --- a/src-gpui/src/ui/mod.rs +++ b/src-gpui/src/ui/mod.rs @@ -4,6 +4,10 @@ pub fn eid(s: impl Into) -> SharedString { SharedString::from(s.into()) } +pub fn leftover_branch_glyph(color: gpui::Hsla) -> gpui::Div { + div().text_color(color).child("⎇") +} + pub fn tip(text: impl Into) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static { let text = text.into(); move |window, cx| gpui_component::tooltip::Tooltip::new(text.clone()).build(window, cx) From c456f5d115133ddba58746a037a1c1b0a5c011d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:39:40 +0000 Subject: [PATCH 38/50] Match leftover ProjectTopBar chrome and DebugLog dialog. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Center the project name and branch with leftover px-32 padding, a semibold name, and the branch glyph. Show git +/− only when leftover toGitDiffSummary would, and open Debug Log as the leftover 32rem dialog with search, trash clear, level badges, and filtered/total footer. Cmd+Shift+D now toggles that dialog only when debug mode is on. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 30 ++++++- src-gpui/src/backend.rs | 26 ++++++ src-gpui/src/ui/debug.rs | 161 ++++++++++++++++++++++++----------- src-gpui/src/ui/dialogs.rs | 16 +++- src-gpui/src/ui/settings.rs | 8 +- src-gpui/src/ui/shell.rs | 1 - src-gpui/src/ui/workspace.rs | 158 ++++++++++++++++++++-------------- 7 files changed, 277 insertions(+), 123 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 80773549..67c7e6a8 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -2655,6 +2655,29 @@ impl AppView { ]); } + pub fn open_debug_log(&mut self, window: &mut Window, cx: &mut Context) { + if !self.data.prefs.debug_mode { + return; + } + self.inputs.debug_search.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + self.data.overlay.dialog = Some(DialogKind::DebugLog); + self.data.overlay.debug_open = true; + } + + pub fn toggle_debug_log(&mut self, window: &mut Window, cx: &mut Context) { + if !self.data.prefs.debug_mode { + return; + } + if self.data.overlay.dialog == Some(DialogKind::DebugLog) { + self.data.overlay.dialog = None; + self.data.overlay.debug_open = false; + return; + } + self.open_debug_log(window, cx); + } + /// Close the topmost overlay. Returns true when something was dismissed. pub fn dismiss_overlay(&mut self) -> bool { if self.data.overlay.context_menu.is_some() @@ -2700,6 +2723,7 @@ impl AppView { if self.data.overlay.dialog.take().is_some() { self.data.overlay.dialog_error = None; self.data.overlay.dialog_busy = false; + self.data.overlay.debug_open = false; return true; } if self.data.overlay.debug_open { @@ -3152,10 +3176,8 @@ impl gpui::Render for AppView { .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { ui::settings::open_settings_window(this, window, cx); })) - .on_action(cx.listener(|this, _: &ToggleDebug, _, cx| { - this.data.prefs.debug_mode = !this.data.prefs.debug_mode; - this.data.overlay.debug_open = this.data.prefs.debug_mode; - this.persist_prefs(); + .on_action(cx.listener(|this, _: &ToggleDebug, window, cx| { + this.toggle_debug_log(window, cx); cx.notify(); })) .on_action(cx.listener(|this, _: &OpenPalette, _, cx| { diff --git a/src-gpui/src/backend.rs b/src-gpui/src/backend.rs index 29e6aa18..cc6ee2e8 100644 --- a/src-gpui/src/backend.rs +++ b/src-gpui/src/backend.rs @@ -710,6 +710,16 @@ pub fn format_debug_time(timestamp_ms: u64) -> String { format!("{h:02}:{m:02}:{s:02}.{ms:03}") } +pub fn leftover_debug_log_matches(entry: &model::debug::LogEntry, query: &str) -> bool { + if query.is_empty() { + return true; + } + let q = query.to_ascii_lowercase(); + entry.message.to_ascii_lowercase().contains(&q) + || entry.source.to_ascii_lowercase().contains(&q) + || entry.level.to_ascii_lowercase().contains(&q) +} + pub fn format_debug_log(entry: &model::debug::LogEntry) -> String { format!( "{} {} {} {}", @@ -769,4 +779,20 @@ mod tests { }); assert_eq!(line, "00:00:01.000 INFO pty ready"); } + + #[test] + fn leftover_debug_search_matches_message_source_level() { + let entry = model::debug::LogEntry { + timestamp: 1_000, + level: "WARN".into(), + source: "pty".into(), + message: "ready".into(), + }; + assert!(leftover_debug_log_matches(&entry, "")); + assert!(leftover_debug_log_matches(&entry, "rea")); + assert!(leftover_debug_log_matches(&entry, "PTY")); + assert!(leftover_debug_log_matches(&entry, "warn")); + assert!(!leftover_debug_log_matches(&entry, "00:00")); + assert!(!leftover_debug_log_matches(&entry, "error")); + } } diff --git a/src-gpui/src/ui/debug.rs b/src-gpui/src/ui/debug.rs index 4ebd81bc..fcc168e0 100644 --- a/src-gpui/src/ui/debug.rs +++ b/src-gpui/src/ui/debug.rs @@ -1,9 +1,31 @@ use gpui::{div, prelude::*, px, Context, Window}; -use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::button::{Button, ButtonRounded, ButtonVariants}; use gpui_component::input::Input; -use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName}; use crate::app::AppView; +use crate::backend; + +pub fn leftover_debug_count(filtered: usize, total: usize) -> String { + format!("{filtered} / {total}") +} + +pub fn leftover_debug_level_variant(level: &str) -> &'static str { + match level { + "ERROR" => "destructive", + "WARN" => "secondary", + "INFO" => "default", + _ => "secondary", + } +} + +fn leftover_level_colors(level: &str, theme: &gpui_component::Theme) -> (gpui::Hsla, gpui::Hsla) { + match leftover_debug_level_variant(level) { + "destructive" => (theme.danger, theme.danger_foreground), + "default" => (theme.primary, theme.primary_foreground), + _ => (theme.secondary, theme.secondary_foreground), + } +} pub fn render_fab(app: &AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if !app.data.prefs.debug_mode { @@ -17,11 +39,13 @@ pub fn render_fab(app: &AppView, _window: &mut Window, cx: &mut Context .bottom(px(16.)) .child( Button::new("debug-toggle") + .primary() .icon(IconName::Inspector) + .rounded(ButtonRounded::Size(px(16.))) .tooltip(app.t("debugLog")) - .on_click(move |_, _, cx| { + .on_click(move |_, window, cx| { view.update(cx, |app, cx| { - app.data.overlay.debug_open = !app.data.overlay.debug_open; + app.open_debug_log(window, cx); cx.notify(); }); }), @@ -29,60 +53,37 @@ pub fn render_fab(app: &AppView, _window: &mut Window, cx: &mut Context .into_any_element() } -pub fn render_panel(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if !app.data.overlay.debug_open && app.data.overlay.dialog != Some(crate::state::DialogKind::DebugLog) { - return div().id("debug-panel-off").into_any_element(); - } - if !app.data.overlay.debug_open { - return debug_body(app, cx).into_any_element(); - } - let theme = cx.theme().clone(); - div() - .id("debug-panel") - .absolute() - .right(px(16.)) - .bottom(px(64.)) - .w(px(420.)) - .h(px(280.)) - .rounded_lg() - .bg(theme.background) - .border_1() - .border_color(theme.border) - .shadow_lg() - .child(debug_body(app, cx)) - .into_any_element() -} - -fn debug_body(app: &mut AppView, cx: &mut Context) -> impl IntoElement { +pub fn render_dialog_body(app: &mut AppView, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); let theme = cx.theme().clone(); let q = app.inputs.debug_search.read(cx).value().to_string(); + let total = app.data.overlay.debug_logs.len(); let logs: Vec<_> = app .data .overlay .debug_logs .iter() - .filter(|l| { - q.is_empty() - || crate::backend::format_debug_log(l) - .to_ascii_lowercase() - .contains(&q.to_ascii_lowercase()) - }) + .filter(|l| backend::leftover_debug_log_matches(l, &q)) .cloned() .collect(); + let filtered = logs.len(); v_flex() - .size_full() + .id("debug-log-body") + .w_full() + .min_h(px(280.)) + .max_h(px(520.)) + .gap_2() .child( h_flex() .w_full() - .px_2() - .py_1() .gap_2() + .px_1() .child(div().flex_1().child(Input::new(&app.inputs.debug_search))) .child( Button::new("debug-clear") - .xsmall() - .label(app.t("debugClear")) + .ghost() + .icon(IconName::Delete) + .tooltip(app.t("debugClear")) .on_click({ let view = view.clone(); move |_, _, cx| { @@ -95,40 +96,100 @@ fn debug_body(app: &mut AppView, cx: &mut Context) -> impl IntoElement ), ) .child(if logs.is_empty() { - div().flex_1().p_4().child(app.t("debugNoLogs")).into_any_element() + div() + .flex_1() + .w_full() + .py_8() + .flex() + .items_center() + .justify_center() + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("debugNoLogs")), + ) + .into_any_element() } else { v_flex() + .id("debug-log-list") .flex_1() - .p_2() + .w_full() + .min_h(px(160.)) + .overflow_y_scroll() .children(logs.into_iter().map(|l| { - let level_color = match l.level.as_str() { - "ERROR" => gpui::rgb(0xef4444), - "WARN" => gpui::rgb(0xf59e0b), - _ => gpui::rgb(0x22c55e), - }; + let (badge_bg, badge_fg) = leftover_level_colors(&l.level, &theme); h_flex() + .id(crate::ui::eid(format!("dbg-{}-{}", l.timestamp, l.source))) + .w_full() + .items_start() .gap_2() + .px_3() + .py(px(2.)) + .hover(|el| el.bg(theme.muted)) .child( div() + .flex_shrink_0() .text_xs() .font_family("monospace") .text_color(theme.muted_foreground) - .child(crate::backend::format_debug_time(l.timestamp)), + .child(backend::format_debug_time(l.timestamp)), ) .child( div() + .flex_shrink_0() + .h(px(16.)) + .px(px(6.)) + .rounded_md() + .bg(badge_bg) + .text_color(badge_fg) .text_xs() .font_family("monospace") - .text_color(level_color) .child(l.level.clone()), ) .child( div() + .flex_shrink_0() + .text_xs() + .font_family("monospace") + .text_color(theme.muted_foreground) + .child(l.source.clone()), + ) + .child( + div() + .flex_1() .text_xs() .font_family("monospace") - .child(format!("{} {}", l.source, l.message)), + .child(l.message.clone()), ) })) .into_any_element() }) + .child( + h_flex().w_full().justify_end().px_1().py_1().child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(leftover_debug_count(filtered, total)), + ), + ) +} + +#[cfg(test)] +mod tests { + use super::{leftover_debug_count, leftover_debug_level_variant}; + + #[test] + fn leftover_debug_footer_matches_inventory() { + assert_eq!(leftover_debug_count(3, 10), "3 / 10"); + assert_eq!(leftover_debug_count(0, 0), "0 / 0"); + } + + #[test] + fn leftover_debug_level_badges() { + assert_eq!(leftover_debug_level_variant("ERROR"), "destructive"); + assert_eq!(leftover_debug_level_variant("WARN"), "secondary"); + assert_eq!(leftover_debug_level_variant("INFO"), "default"); + assert_eq!(leftover_debug_level_variant("TRACE"), "secondary"); + } } diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index 495fd268..bf130514 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -65,12 +65,14 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> 720. } else if matches!(kind, DialogKind::SwitchBranch) { 448. - } else if matches!(kind, DialogKind::ProjectSettings | DialogKind::DebugLog) { + } else if matches!(kind, DialogKind::ProjectSettings) { 560. + } else if matches!(kind, DialogKind::DebugLog) { + 512. } else { 380. })) - .max_h(px(640.)) + .max_h(px(if matches!(kind, DialogKind::DebugLog) { 630. } else { 640. })) .p_4() .gap_3() .rounded_xl() @@ -541,7 +543,7 @@ fn dialog_body( .child(Input::new(&app.inputs.template_commands)) .into_any_element(), DialogKind::ReviewQueue => review_queue_body(app, theme.muted_foreground, cx).into_any_element(), - DialogKind::DebugLog => crate::ui::debug::render_panel(app, _window, cx).into_any_element(), + DialogKind::DebugLog => crate::ui::debug::render_dialog_body(app, cx).into_any_element(), DialogKind::CreateGroup => v_flex() .gap_2() .child(div().text_sm().child(app.t("projectGroupName"))) @@ -783,6 +785,9 @@ fn leftover_branch_row( } fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> impl IntoElement { + if kind == DialogKind::DebugLog { + return div().id("no-debug-footer").into_any_element(); + } let view = cx.entity(); let danger = matches!( kind, @@ -817,7 +822,9 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> .justify_end() .gap_2() .when( - kind != DialogKind::ReviewQueue && kind != DialogKind::SwitchBranch, + kind != DialogKind::ReviewQueue + && kind != DialogKind::SwitchBranch + && kind != DialogKind::DebugLog, |el| { el.child(Button::new("dlg-cancel").small().label(app.t("cancel")).on_click({ let view = view.clone(); @@ -919,6 +926,7 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> }), ) }) + .into_any_element() } fn context_menu(app: &AppView, cx: &mut Context) -> impl IntoElement { diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 92c82c63..6b351517 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -16,7 +16,7 @@ use gpui_component::tab::Tab; use crate::app::AppView; use crate::i18n::Locale; use crate::prefs::{term_theme_by_name, RadiusPref, TERM_THEMES, ThemePref}; -use crate::state::SettingsTab; +use crate::state::{DialogKind, SettingsTab}; pub fn open_update_page(app: &mut AppView, window: &mut Window, cx: &mut Context) { open_settings_at(app, window, cx, Some(SettingsTab::About)); @@ -189,6 +189,12 @@ impl SettingsView { main.update(cx, |app, cx| { app.data.prefs = prefs; app.data.locale = locale; + if !app.data.prefs.debug_mode { + if app.data.overlay.dialog == Some(DialogKind::DebugLog) { + app.data.overlay.dialog = None; + } + app.data.overlay.debug_open = false; + } app.persist_prefs(); cx.notify(); }); diff --git a/src-gpui/src/ui/shell.rs b/src-gpui/src/ui/shell.rs index 1ad50a2e..9c49ef23 100644 --- a/src-gpui/src/ui/shell.rs +++ b/src-gpui/src/ui/shell.rs @@ -83,7 +83,6 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .child(palette::render(app, window, cx)) .child(crate::ui::git::render_diff_dialog(app, window, cx)) .child(debug::render_fab(app, window, cx)) - .child(debug::render_panel(app, window, cx)) .child(toasts(app, cx)) } diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 0a0c51c9..740252e3 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -6,7 +6,7 @@ use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::state::{AgentKind, DialogKind, SidebarMode, UnifiedTab}; -use crate::ui::{file_tree, file_viewer, git, notes, sidebar, terminal}; +use crate::ui::{file_tree, file_viewer, git, leftover_branch_glyph, notes, sidebar, terminal, tip}; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() @@ -66,51 +66,26 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> .flex() .items_end() .justify_between() + .relative() .child( h_flex() - .gap_2() - .when(collapsed, |el| { - el.child( - Button::new("expand-app-sidebar") - .ghost() - .small() - .icon(IconName::PanelLeftOpen) - .tooltip(app.t("expandSidebar")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.prefs.sidebar_collapsed = false; - app.persist_prefs(); - cx.notify(); - }); - } - }), - ) - }) - .child(mode_switch( - app, - mode, - open, - stats.files_changed, - stats.insertions, - stats.deletions, - cx, - )), - ) - .child( - h_flex() + .id("topbar-title") .absolute() .left_0() .right_0() + .bottom(px(6.)) + .px(px(128.)) + .min_w_0() + .items_center() .justify_center() .gap_2() .child( - Button::new("topbar-project-name") - .ghost() - .small() - .label(name) - .tooltip(worktree.clone()) + div() + .id("topbar-project-name") + .min_w_0() + .font_semibold() + .truncate() + .tooltip(tip(worktree.clone())) .on_click({ let view = view.clone(); move |_, _, cx| { @@ -120,14 +95,18 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> .reveal_path(app.data.current_profile.as_deref().unwrap_or(""), None); }); } - }), + }) + .child(name), ) .child( h_flex() .id("topbar-branch") + .min_w_0() .gap_1() + .items_center() .text_color(theme.muted_foreground) .hover(|el| el.text_color(theme.foreground)) + .tooltip(tip(app.t("switchBranchTitle"))) .on_click({ let view = view.clone(); move |_, _, cx| { @@ -141,10 +120,34 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> }); } }) - .child(Icon::new(IconName::GitHub).w(px(12.))) - .child(div().text_sm().child(branch)), + .child(leftover_branch_glyph(theme.muted_foreground)) + .child(div().min_w_0().truncate().child(branch)), ), ) + .child( + h_flex() + .gap_2() + .when(collapsed, |el| { + el.child( + Button::new("expand-app-sidebar") + .ghost() + .small() + .icon(IconName::PanelLeftOpen) + .tooltip(app.t("expandSidebar")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.prefs.sidebar_collapsed = false; + app.persist_prefs(); + cx.notify(); + }); + } + }), + ) + }) + .child(mode_switch(app, mode, open, stats.insertions, stats.deletions, cx)), + ) .child( h_flex() .gap_1() @@ -189,17 +192,21 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> .into_any_element() } +pub fn leftover_show_git_diff_stats(insertions: u32, deletions: u32) -> bool { + insertions != 0 || deletions != 0 +} + fn mode_switch( app: &AppView, mode: SidebarMode, open: bool, - files: u32, ins: u32, del: u32, cx: &mut Context, ) -> impl IntoElement { let view = cx.entity(); let theme = cx.theme().clone(); + let git_selected = open && mode == SidebarMode::Git; h_flex() .id("sidebar-mode-switch") .h(px(28.)) @@ -218,23 +225,39 @@ fn mode_switch( .child( h_flex() .id("git-mode-btn") - .child(mode_btn( - "git", - IconName::GitHub, - app.t("sidebarGitTab"), - open && mode == SidebarMode::Git, - SidebarMode::Git, - view.clone(), - )) - .when(files > 0, |el| { - el.child( - h_flex() - .gap_1() - .pr_1() - .text_xs() - .child(div().text_color(rgb(0x22c55e)).child(format!("+{ins}"))) - .child(div().text_color(rgb(0xef4444)).child(format!("-{del}"))), - ) + .h(px(24.)) + .px_2() + .gap_1() + .items_center() + .rounded_md() + .when(git_selected, |el| el.bg(theme.background)) + .tooltip(tip(app.t("sidebarGitTab"))) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + if ws.sidebar_open && ws.sidebar_mode == SidebarMode::Git { + ws.sidebar_open = false; + } else { + ws.sidebar_mode = SidebarMode::Git; + ws.sidebar_open = true; + } + app.data.prefs.profile_sidebar_open = ws.sidebar_open; + app.persist_prefs(); + } + cx.notify(); + }); + } + }) + .child(leftover_branch_glyph(if git_selected { + theme.foreground + } else { + theme.muted_foreground + })) + .when(leftover_show_git_diff_stats(ins, del), |el| { + el.child(div().text_xs().text_color(rgb(0x22c55e)).child(format!("+{ins}"))) + .child(div().text_xs().text_color(rgb(0xef4444)).child(format!("-{del}"))) }), ) .child(mode_btn( @@ -288,7 +311,6 @@ fn topbar_control( let view = cx.entity(); match id { "github-desktop" => Button::new("tb-gh") - .ghost() .small() .icon(IconName::GitHub) .tooltip(app.t("topbarGithubDesktop")) @@ -300,7 +322,6 @@ fn topbar_control( }) .into_any_element(), "editor" => Button::new("tb-ed") - .ghost() .small() .icon(IconName::ALargeSmall) .tooltip(app.t("topbarEditor")) @@ -312,7 +333,6 @@ fn topbar_control( }) .into_any_element(), "terminal" => Button::new("tb-term") - .ghost() .small() .icon(IconName::SquareTerminal) .tooltip(app.t("topbarTerminal")) @@ -357,7 +377,6 @@ fn topbar_control( ), }; Button::new("tb-pr") - .ghost() .small() .icon(IconName::GitHub) .label(label) @@ -786,3 +805,16 @@ fn persistent_terminals(app: &mut AppView, window: &mut Window, cx: &mut Context .child(terminal::render(app, &profile_id, index, visible, window, cx)) })) } + +#[cfg(test)] +mod tests { + use super::leftover_show_git_diff_stats; + + #[test] + fn leftover_git_stats_hide_clean_trees() { + assert!(!leftover_show_git_diff_stats(0, 0)); + assert!(leftover_show_git_diff_stats(1, 0)); + assert!(leftover_show_git_diff_stats(0, 4)); + assert!(leftover_show_git_diff_stats(2, 3)); + } +} From 065ce9573f5943880c091810c870883473dac0cb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:43:48 +0000 Subject: [PATCH 39/50] Match leftover CommitComposer, git header, and Sonner toasts. Leftover composer shows Push only on a clean tree and Commit only when files exist, without the extra All/None row. Select-all is tri-state, commit toasts use a 7-char hash and clear the form, and toasts render leftover Sonner icons on a popover surface. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 15 +- src-gpui/src/ui/file_tree.rs | 2 +- src-gpui/src/ui/git.rs | 398 +++++++++++++++++++++-------------- src-gpui/src/ui/shell.rs | 109 +++++++--- 4 files changed, 330 insertions(+), 194 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 67c7e6a8..225da940 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -1732,7 +1732,7 @@ impl AppView { } } - pub fn commit_selected(&mut self, cx: &mut Context) { + pub fn commit_selected(&mut self, window: &mut Window, cx: &mut Context) { let Some(profile_id) = self.data.current_profile.clone() else { return; }; @@ -1752,11 +1752,18 @@ impl AppView { }; match self.backend.commit_changes(&profile_id, &files, &summary, body) { Ok(hash) => { + let short = crate::ui::git::leftover_short_hash(&hash); self.data.push_toast( ToastKind::Success, self.t("gitCommitSuccessTitle"), - i18n::tf(self.data.locale, "gitCommitSuccessDescription", &[("hash", &hash)]), + i18n::tf(self.data.locale, "gitCommitSuccessDescription", &[("hash", &short)]), ); + self.inputs.commit_summary.update(cx, |s, cx| { + s.set_value("", window, cx); + }); + self.inputs.commit_body.update(cx, |s, cx| { + s.set_value("", window, cx); + }); self.refresh_workspace(&profile_id); } Err(err) => self @@ -3230,7 +3237,7 @@ impl gpui::Render for AppView { this.save_active_file(window, cx); cx.notify(); })) - .on_action(cx.listener(|this, _: &CommitChanges, _, cx| { + .on_action(cx.listener(|this, _: &CommitChanges, window, cx| { let (no_files, ahead) = this .data .current_ws() @@ -3239,7 +3246,7 @@ impl gpui::Render for AppView { if no_files && ahead > 0 { this.push_current(); } else { - this.commit_selected(cx); + this.commit_selected(window, cx); } cx.notify(); })) diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index c059fe0c..808f27e3 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -22,7 +22,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) v_flex() .id("file-tree") .size_full() - .px_1() + .px(px(6.)) .py_1() .text_size(px(13.)) .tab_index(0) diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index c24b1e74..09d2d9bf 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -2,7 +2,7 @@ use gpui::{div, img, prelude::*, px, Context, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::checkbox::Checkbox; use gpui_component::input::Input; -use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; use gpui_component::{Disableable, Selectable}; use crate::app::{extract_file_hunk, file_status_badge, AppView}; @@ -28,63 +28,21 @@ pub fn render_panel(app: &mut AppView, window: &mut Window, cx: &mut Context, delta: i32, count: usi }) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeftoverIncludeState { + Empty, + None, + Partial, + All, +} + +pub fn leftover_include_state(total: usize, included: usize) -> LeftoverIncludeState { + if total == 0 { + LeftoverIncludeState::Empty + } else if included == 0 { + LeftoverIncludeState::None + } else if included == total { + LeftoverIncludeState::All + } else { + LeftoverIncludeState::Partial + } +} + +pub fn leftover_composer_shows_push(total_files: usize) -> bool { + total_files == 0 +} + +pub fn leftover_short_hash(hash: &str) -> String { + hash.chars().take(7).collect() +} + pub fn leftover_change_badge(status: &str) -> (&'static str, &'static str) { match file_status_badge(status) { "A" => ("A", "green"), @@ -133,6 +119,122 @@ pub fn leftover_change_badge(status: &str) -> (&'static str, &'static str) { } } +fn leftover_changes_header( + app: &AppView, + files: &[(String, String)], + included: usize, + show_maximize: bool, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let view = cx.entity(); + let state = leftover_include_state(files.len(), included); + let names: Vec = files.iter().map(|(p, _)| p.clone()).collect(); + h_flex() + .id("git-changes-header") + .w_full() + .px_3() + .py(px(10.)) + .gap_2() + .bg(theme.background) + .border_b_1() + .border_color(theme.border) + .child(leftover_include_checkbox( + "git-all", + state, + names, + app.t("gitCommitIncludeAll"), + view.clone(), + )) + .child( + div() + .flex_1() + .text_xs() + .text_color(theme.muted_foreground) + .child(crate::i18n::tf( + app.data.locale, + "changedFiles", + &[("count", &files.len().to_string())], + )), + ) + .when(show_maximize, |el| { + el.child( + Button::new("open-diff") + .ghost() + .xsmall() + .icon(IconName::Maximize) + .tooltip(app.t("gitOpenDiffView")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.open_git_diff(); + cx.notify(); + }); + } + }), + ) + }) +} + +fn leftover_include_checkbox( + id: &'static str, + state: LeftoverIncludeState, + names: Vec, + tip: String, + view: gpui::Entity, +) -> impl IntoElement { + let include_all = { + let view = view.clone(); + let names = names.clone(); + move |cx: &mut gpui::App| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.git_included = names.iter().cloned().collect(); + } + cx.notify(); + }); + } + }; + let include_none = { + let view = view.clone(); + move |cx: &mut gpui::App| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + ws.git_included.clear(); + } + cx.notify(); + }); + } + }; + if state == LeftoverIncludeState::Partial { + return h_flex() + .id(id) + .w(px(16.)) + .h(px(16.)) + .items_center() + .justify_center() + .rounded(px(4.)) + .border_1() + .border_color(gpui::hsla(0., 0., 0.55, 0.45)) + .tooltip(crate::ui::tip(tip)) + .on_click(move |_, _, cx| include_all(cx)) + .child(Icon::new(IconName::Dash).w(px(12.))) + .into_any_element(); + } + let _ = tip; + Checkbox::new(id) + .checked(state == LeftoverIncludeState::All) + .on_click(move |checked, _, cx| { + if *checked { + include_all(cx); + } else { + include_none(cx); + } + }) + .into_any_element() +} + fn leftover_badge_color(kind: &str) -> gpui::Hsla { match kind { "green" => gpui::hsla(142. / 360., 0.70, 0.45, 1.), @@ -282,122 +384,64 @@ fn commit_composer( let view = cx.entity(); let theme = cx.theme().clone(); let summary_empty = app.inputs.commit_summary.read(cx).value().trim().is_empty(); + let no_files = leftover_composer_shows_push(total); + let can_commit = !no_files && included > 0 && !summary_empty; v_flex() .id("commit-composer") .w_full() .border_t_1() .border_color(theme.border) - .px_2() + .px(px(10.)) .py_2() .gap_2() .child( div() .text_xs() - .font_semibold() + .font_medium() + .text_color(theme.muted_foreground) .child(app.t("gitCommitSectionTitle").to_ascii_uppercase()), ) .child(div().text_xs().child(app.t("gitCommitSummary"))) - .child(Input::new(&app.inputs.commit_summary)) + .child(Input::new(&app.inputs.commit_summary).disabled(no_files)) .child(div().text_xs().child(app.t("gitCommitBody"))) - .child(Input::new(&app.inputs.commit_body)) - .child( - h_flex() - .justify_between() - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(crate::i18n::tf( - app.data.locale, - "gitCommitIncludedCount", - &[ - ("includedCount", &included.to_string()), - ("totalCount", &total.to_string()), - ], - )), - ) - .child( - h_flex() - .gap_1() - .child( - Button::new("inc-all") - .ghost() - .xsmall() - .label(app.t("gitCommitIncludeAll")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - if let Some(ws) = app.data.current_ws_mut() { - ws.git_included = ws.git_files.iter().map(|(p, _)| p.clone()).collect(); - } - cx.notify(); - }); - } - }), - ) - .child( - Button::new("inc-none") - .ghost() - .xsmall() - .label(app.t("gitCommitIncludeNone")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - if let Some(ws) = app.data.current_ws_mut() { - ws.git_included.clear(); - } - cx.notify(); - }); - } - }), - ), - ), - ) - .child( - h_flex() - .gap_2() - .child( - Button::new("commit") - .primary() - .small() - .label(app.t("gitCommitButton")) - .disabled(included == 0 || summary_empty) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.commit_selected(cx); - cx.notify(); - }); - } - }), - ) - .when(ahead > 0, |el| { - el.child( - Button::new("push") - .small() - .icon(IconName::ArrowUp) - .label(app.t("gitPushButton")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.push_current(); - cx.notify(); - }); - } - }), - ) - }), - ) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(app.t("gitCommitShortcutHint")), - ) + .child(Input::new(&app.inputs.commit_body).disabled(no_files)) + .child(h_flex().w_full().justify_end().child(if no_files { + Button::new("push") + .small() + .disabled(ahead == 0) + .icon(IconName::ArrowUp) + .label(if ahead > 0 { + format!("{ahead} {}", app.t("gitPushButton")) + } else { + app.t("gitPushButton") + }) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.push_current(); + cx.notify(); + }); + } + }) + .into_any_element() + } else { + Button::new("commit") + .primary() + .small() + .label(app.t("gitCommitButton")) + .disabled(!can_commit) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.commit_selected(window, cx); + cx.notify(); + }); + } + }) + .into_any_element() + })) } pub fn render_diff_dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -568,7 +612,20 @@ pub fn render_diff_dialog(app: &mut AppView, window: &mut Window, cx: &mut Conte .flex_1() .min_h_0() .child(if files.is_empty() { - div().p_4().child(app.t("noChangesDetected")).into_any_element() + div() + .flex() + .flex_1() + .w_full() + .items_center() + .justify_center() + .p_8() + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("noChangesDetected")), + ) + .into_any_element() } else { v_flex() .flex_1() @@ -1212,7 +1269,10 @@ fn image_preview(app: &AppView, path: &str, diff: &str) -> impl IntoElement { #[cfg(test)] mod tests { - use super::{file_display_parts, leftover_change_badge, leftover_step_commit_index, leftover_step_index}; + use super::{ + file_display_parts, leftover_change_badge, leftover_composer_shows_push, leftover_include_state, + leftover_short_hash, leftover_step_commit_index, leftover_step_index, LeftoverIncludeState, + }; #[test] fn leftover_file_display_parts_split_git_paths() { @@ -1252,4 +1312,24 @@ mod tests { assert_eq!(leftover_change_badge("A"), ("A", "green")); assert_eq!(leftover_change_badge("M"), ("M", "blue")); } + + #[test] + fn leftover_composer_push_only_when_clean() { + assert!(leftover_composer_shows_push(0)); + assert!(!leftover_composer_shows_push(1)); + } + + #[test] + fn leftover_include_state_is_tri_state() { + assert_eq!(leftover_include_state(0, 0), LeftoverIncludeState::Empty); + assert_eq!(leftover_include_state(3, 0), LeftoverIncludeState::None); + assert_eq!(leftover_include_state(3, 2), LeftoverIncludeState::Partial); + assert_eq!(leftover_include_state(3, 3), LeftoverIncludeState::All); + } + + #[test] + fn leftover_commit_hash_is_seven_chars() { + assert_eq!(leftover_short_hash("abcdef1234567890"), "abcdef1"); + assert_eq!(leftover_short_hash("abc"), "abc"); + } } diff --git a/src-gpui/src/ui/shell.rs b/src-gpui/src/ui/shell.rs index 9c49ef23..398908f5 100644 --- a/src-gpui/src/ui/shell.rs +++ b/src-gpui/src/ui/shell.rs @@ -1,11 +1,20 @@ use gpui::{div, prelude::*, px, rgb, Context, KeyDownEvent, MouseButton, Window}; use gpui_component::button::{Button, ButtonVariants}; -use gpui_component::{ActiveTheme, Sizable, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; use crate::app::AppView; -use crate::state::{Route, ToastAction}; +use crate::state::{Route, ToastAction, ToastKind}; use crate::ui::{debug, dialogs, palette, settings, sidebar, workspace}; +pub fn leftover_toast_icon(kind: ToastKind) -> IconName { + match kind { + ToastKind::Success => IconName::CircleCheck, + ToastKind::Info => IconName::Info, + ToastKind::Warning => IconName::TriangleAlert, + ToastKind::Error => IconName::CircleX, + } +} + pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); let view = cx.entity(); @@ -102,17 +111,19 @@ fn toasts(app: &AppView, cx: &mut Context) -> impl IntoElement { .flex_col() .gap_2() .children(app.data.toasts.iter().rev().take(4).map(|toast| { - let bg = theme.background; let action = toast.action; - div() + let icon = leftover_toast_icon(toast.kind); + h_flex() .id(crate::ui::eid(format!("toast-{}", toast.id))) - .w(px(320.)) + .w(px(356.)) + .gap_2() + .items_start() .px_3() .py_2() .rounded_lg() .border_1() .border_color(theme.border) - .bg(bg) + .bg(theme.popover) .shadow_md() .on_click({ let view = view.clone(); @@ -124,30 +135,37 @@ fn toasts(app: &AppView, cx: &mut Context) -> impl IntoElement { }); } }) - .child(div().text_sm().font_semibold().child(toast.title.clone())) - .when(!toast.body.is_empty(), |el| { - el.child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(toast.body.clone()), - ) - }) - .when(action == Some(ToastAction::OpenAbout), |el| { - el.child( - Button::new(crate::ui::eid(format!("toast-act-{}", toast.id))) - .xsmall() - .label(app.t("openUpdatePage")) - .on_click({ - let view = view.clone(); - move |_, window, cx| { - view.update(cx, |app, cx| { - settings::open_update_page(app, window, cx); - }); - } - }), - ) - }) + .child(Icon::new(icon).w(px(16.)).h(px(16.))) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(div().text_sm().font_semibold().child(toast.title.clone())) + .when(!toast.body.is_empty(), |el| { + el.child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(toast.body.clone()), + ) + }) + .when(action == Some(ToastAction::OpenAbout), |el| { + el.child( + Button::new(crate::ui::eid(format!("toast-act-{}", toast.id))) + .xsmall() + .label(app.t("openUpdatePage")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + settings::open_update_page(app, window, cx); + }); + } + }), + ) + }), + ) })) } @@ -204,3 +222,34 @@ fn win_btn( .on_click(move |_, window, _| on(window)), ) } + +#[cfg(test)] +mod tests { + use super::leftover_toast_icon; + use crate::state::ToastKind; + use gpui_component::IconName; + + fn icon_path(name: IconName) -> String { + gpui_component::IconNamed::path(name).to_string() + } + + #[test] + fn leftover_toast_icons_follow_sonner() { + assert_eq!( + icon_path(leftover_toast_icon(ToastKind::Success)), + icon_path(IconName::CircleCheck) + ); + assert_eq!( + icon_path(leftover_toast_icon(ToastKind::Info)), + icon_path(IconName::Info) + ); + assert_eq!( + icon_path(leftover_toast_icon(ToastKind::Warning)), + icon_path(IconName::TriangleAlert) + ); + assert_eq!( + icon_path(leftover_toast_icon(ToastKind::Error)), + icon_path(IconName::CircleX) + ); + } +} From 81c590d4bd935732f165ec6b6555082726d96c46 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:43:53 +0000 Subject: [PATCH 40/50] Drop unused git panel view binding. Co-authored-by: AkaraChen --- src-gpui/src/ui/git.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index 09d2d9bf..7122b192 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -13,7 +13,6 @@ use crate::timefmt; pub fn render_panel(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); - let view = cx.entity(); let Some(ws) = app.data.current_ws() else { return div().id("git-panel-empty").into_any_element(); }; From 4be0e8728b17276f66c87f6add8e285bb1332dfb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 08:47:54 +0000 Subject: [PATCH 41/50] Match leftover sidebar profile sublist and empty terminal CTA. Leftover ProjectMenuItem only shows Default / extra profiles / New Profile when a project has extra profiles; a single default stays a row with + create. Non-default rows use the leftover branch glyph, and the empty-terminal CTA matches leftover Empty media (32px muted tile) without a forced split button. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 2 +- src-gpui/src/state.rs | 31 +++++++++++---- src-gpui/src/ui/sidebar.rs | 73 ++++++++++++++++++++++++------------ src-gpui/src/ui/workspace.rs | 17 +++++++-- 4 files changed, 88 insertions(+), 35 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 225da940..ee04584d 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -2758,7 +2758,7 @@ impl AppView { &self.data.projects, &self.data.groups, &self.data.prefs.collapsed_groups, - &self.data.overlay.expanded_projects, + &self.data.overlay.collapsed_projects, self.data.current_project.as_deref(), ) } diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index c539f428..f6d8e54d 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -616,7 +616,7 @@ pub struct OverlayState { pub settings_tab: SettingsTab, pub debug_open: bool, pub debug_logs: Vec, - pub expanded_projects: HashSet, + pub collapsed_projects: HashSet, pub pending_close_file: Option, pub editing_template: Option, pub project_settings_tab: usize, @@ -742,12 +742,20 @@ impl AppData { } } +pub fn leftover_profile_sublist_open(has_extras: bool, collapsed: bool) -> bool { + has_extras && !collapsed +} + +pub fn leftover_project_row_active(has_extras: bool, default_is_current: bool) -> bool { + !has_extras && default_is_current +} + pub fn collect_sidebar_nav_items( projects: &[ProjectWithProfiles], groups: &[ProjectGroup], collapsed_groups: &[String], - expanded_projects: &HashSet, - current_project: Option<&str>, + collapsed_projects: &HashSet, + _current_project: Option<&str>, ) -> Vec { let mut items = Vec::new(); if projects.is_empty() { @@ -756,10 +764,8 @@ pub fn collect_sidebar_nav_items( } let mut push_project = |project: &ProjectWithProfiles| { items.push(SidebarNavItem::Project(project.id.clone())); - let extras: Vec<_> = project.profiles.iter().filter(|p| !p.is_default).collect(); - let selected = current_project == Some(project.id.as_str()); - let expanded = expanded_projects.contains(&project.id) || extras.is_empty() || selected; - if expanded { + let has_extras = project.profiles.iter().any(|p| !p.is_default); + if leftover_profile_sublist_open(has_extras, collapsed_projects.contains(&project.id)) { for profile in &project.profiles { items.push(SidebarNavItem::Profile { project_id: project.id.clone(), @@ -837,6 +843,17 @@ mod tests { ); } + #[test] + fn leftover_profile_sublist_only_when_extras_exist() { + assert!(!leftover_profile_sublist_open(false, false)); + assert!(!leftover_profile_sublist_open(false, true)); + assert!(leftover_profile_sublist_open(true, false)); + assert!(!leftover_profile_sublist_open(true, true)); + assert!(leftover_project_row_active(false, true)); + assert!(!leftover_project_row_active(true, true)); + assert!(!leftover_project_row_active(false, false)); + } + #[test] fn project_group_menu_rows_match_inventory() { assert_eq!( diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index 6ef4cf68..bd778801 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -7,8 +7,11 @@ use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::backend; -use crate::state::{AgentStatus, ContextMenu, DialogKind, Route, SidebarNavItem}; -use crate::ui::settings; +use crate::state::{ + leftover_profile_sublist_open, leftover_project_row_active, AgentStatus, ContextMenu, DialogKind, Route, + SidebarNavItem, +}; +use crate::ui::{leftover_branch_glyph, settings}; #[derive(Clone)] struct SidebarDrag { @@ -553,10 +556,16 @@ fn project_row( ) -> impl IntoElement { let theme = cx.theme().clone(); let view = cx.entity(); - let selected = app.data.current_project.as_deref() == Some(project.id.as_str()); let nav_focus = app.data.overlay.sidebar_nav.as_ref() == Some(&SidebarNavItem::Project(project.id.clone())); let extras: Vec<_> = project.profiles.iter().filter(|p| !p.is_default).cloned().collect(); - let expanded = app.data.overlay.expanded_projects.contains(&project.id) || extras.is_empty() || selected; + let has_extras = !extras.is_empty(); + let collapsed = app.data.overlay.collapsed_projects.contains(&project.id); + let expanded = leftover_profile_sublist_open(has_extras, collapsed); + let default_profile = project.profiles.iter().find(|p| p.is_default).map(|p| p.id.clone()); + let default_current = default_profile + .as_deref() + .is_some_and(|id| app.data.current_profile.as_deref() == Some(id)); + let row_active = leftover_project_row_active(has_extras, default_current); let agent = app.agent_for_project(&project.id); let letter = project .name @@ -565,7 +574,6 @@ fn project_row( .map(|c| c.to_ascii_uppercase()) .unwrap_or('?'); let id = project.id.clone(); - let default_profile = project.profiles.iter().find(|p| p.is_default).map(|p| p.id.clone()); v_flex() .id(crate::ui::eid(format!("proj-{}", project.id))) @@ -577,8 +585,8 @@ fn project_row( .py_1() .rounded_md() .gap_2() - .when(selected, |el| el.bg(theme.sidebar_accent)) - .when(nav_focus && !selected, |el| el.border_1().border_color(theme.border)) + .when(row_active, |el| el.bg(theme.sidebar_accent)) + .when(nav_focus && !row_active, |el| el.border_1().border_color(theme.border)) .hover(|el| el.bg(theme.sidebar_accent)) .on_click({ let view = view.clone(); @@ -744,12 +752,13 @@ fn project_row( }), ) }) - .when(!app.data.overlay.sort_mode && extras.is_empty(), |el| { + .when(!app.data.overlay.sort_mode && !has_extras, |el| { el.child(agent_dot(agent)).child( Button::new(crate::ui::eid(format!("add-prof-{}", project.id))) .ghost() .xsmall() .icon(IconName::Plus) + .tooltip(app.t("createProfile")) .on_click({ let view = view.clone(); let id = id.clone(); @@ -763,14 +772,35 @@ fn project_row( }), ) }) - .when(!extras.is_empty(), |el| { + .when(!app.data.overlay.sort_mode && has_extras, |el| { el.child( - Icon::new(if expanded { - IconName::ChevronDown - } else { - IconName::ChevronRight - }) - .w(px(12.)), + Button::new(crate::ui::eid(format!("expand-prof-{}", project.id))) + .ghost() + .xsmall() + .icon(if expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }) + .tooltip(crate::i18n::tf( + app.data.locale, + "toggleProjectGroup", + &[("name", &project.name)], + )) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.collapsed_projects.contains(&id) { + app.data.overlay.collapsed_projects.remove(&id); + } else { + app.data.overlay.collapsed_projects.insert(id.clone()); + } + cx.notify(); + }); + } + }), ) }), ) @@ -849,14 +879,11 @@ fn project_row( }); } }) - .child( - Icon::new(if profile.is_default { - IconName::SquareTerminal - } else { - IconName::GitHub - }) - .w(px(14.)), - ) + .child(if profile.is_default { + Icon::new(IconName::SquareTerminal).w(px(14.)).into_any_element() + } else { + leftover_branch_glyph(theme.muted_foreground).into_any_element() + }) .child(div().flex_1().text_sm().child(label)) .child(agent_dot(app.agent_for_profile(&profile.id))) })) diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 740252e3..5eb18398 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -480,16 +480,25 @@ fn empty_cta(app: &AppView, cx: &mut Context) -> impl IntoElement { .flex_1() .items_center() .justify_center() - .gap_3() - .child(Icon::new(IconName::SquareTerminal).w(px(28.))) - .child(div().font_semibold().child(app.t("noTerminalsOpen"))) + .gap_4() + .child( + div() + .size(px(32.)) + .rounded_lg() + .bg(theme.muted) + .flex() + .items_center() + .justify_center() + .child(Icon::new(IconName::SquareTerminal).w(px(16.))), + ) + .child(div().text_sm().font_medium().child(app.t("noTerminalsOpen"))) .child( div() .text_sm() .text_color(theme.muted_foreground) .child(app.t("noTerminalsOpenDescription")), ) - .child(new_terminal_control(app, view, true)) + .child(new_terminal_control(app, view, false)) } fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { From f056c21f96f18282b90ee8cf0acdae708fd0f48c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 09:03:54 +0000 Subject: [PATCH 42/50] Match leftover dialog chrome and New Terminal hover dropdown. Create/rename/delete dialogs now use leftover title marks, 384/512 widths, popover panel, outline Cancel, Folder+pencil picker, and leftover delete-profile alerts. The tab-bar New Terminal control hover-opens leftover template groups instead of a split caret. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 24 ++-- src-gpui/src/state.rs | 121 +++++++++++++++++ src-gpui/src/ui/dialogs.rs | 257 +++++++++++++++++++++++------------ src-gpui/src/ui/mod.rs | 30 +++- src-gpui/src/ui/workspace.rs | 187 +++++++++++++++++++++++-- 5 files changed, 509 insertions(+), 110 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index ee04584d..d893f3e2 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -1621,7 +1621,15 @@ impl AppView { return; }; let name = self.inputs.rename.read(cx).value().to_string(); - if name.trim().is_empty() { + let init = self + .data + .projects + .iter() + .find(|p| p.id == id) + .map(|p| p.name.clone()) + .unwrap_or_default(); + if crate::state::leftover_rename_disabled(&name, &init) { + self.data.overlay.dialog = None; return; } if let Err(err) = self.backend.rename_project(&id, &name) { @@ -1678,6 +1686,7 @@ impl AppView { self.data.overlay.dialog_profile = Some(id.to_string()); self.data.overlay.dialog_busy = true; self.data.overlay.delete_warning = None; + self.data.overlay.delete_check_failed = false; match self.backend.delete_profile_check(id) { Ok(check) => { self.data.overlay.dialog_busy = false; @@ -1716,18 +1725,11 @@ impl AppView { ], )); } - if !parts.is_empty() { - self.data.overlay.delete_warning = Some(parts.join("\n")); - } + self.data.overlay.delete_warning = crate::state::leftover_delete_profile_warning(&parts); } - Err(err) => { + Err(_) => { self.data.overlay.dialog_busy = false; - self.data.overlay.delete_warning = Some(format!( - "{}\n{}\n{}", - self.t("deleteProfileGitCheckFailedTitle"), - self.t("deleteProfileGitCheckFailedDescription"), - err - )); + self.data.overlay.delete_check_failed = true; } } } diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index f6d8e54d..cdf3d865 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -633,6 +633,8 @@ pub struct OverlayState { pub file_search_ix: usize, pub md_menu: Option, pub md_preview: bool, + pub new_terminal_hover: bool, + pub delete_check_failed: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -750,6 +752,78 @@ pub fn leftover_project_row_active(has_extras: bool, default_is_current: bool) - !has_extras && default_is_current } +pub fn leftover_rename_disabled(name: &str, init: &str) -> bool { + let trimmed = name.trim(); + trimmed.is_empty() || trimmed == init +} + +pub fn leftover_delete_profile_warning(parts: &[String]) -> Option { + if parts.is_empty() { + None + } else { + Some(parts.join(" ")) + } +} + +pub fn leftover_new_terminal_split(has_templates: bool, empty_cta: bool) -> bool { + empty_cta && has_templates +} + +pub fn leftover_dialog_width(kind: DialogKind) -> f32 { + match kind { + DialogKind::ReviewQueue => 896.0, + DialogKind::SwitchBranch => 448.0, + DialogKind::ProjectSettings => 512.0, + DialogKind::DebugLog => 512.0, + _ => 384.0, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LeftoverTemplateRow { + EmptyTitle, + EmptyHint, + ProjectHeader, + Project { index: usize, name: String, cwd: String }, + GlobalHeader, + Global { index: usize, name: String }, +} + +pub fn leftover_template_rows( + project: &[(String, String)], + global: &[String], + show_empty: bool, +) -> Vec { + if project.is_empty() && global.is_empty() { + return if show_empty { + vec![LeftoverTemplateRow::EmptyTitle, LeftoverTemplateRow::EmptyHint] + } else { + Vec::new() + }; + } + let mut rows = Vec::new(); + if !project.is_empty() { + rows.push(LeftoverTemplateRow::ProjectHeader); + for (index, (name, cwd)) in project.iter().enumerate() { + rows.push(LeftoverTemplateRow::Project { + index, + name: name.clone(), + cwd: cwd.clone(), + }); + } + } + if !global.is_empty() { + rows.push(LeftoverTemplateRow::GlobalHeader); + for (index, name) in global.iter().enumerate() { + rows.push(LeftoverTemplateRow::Global { + index, + name: name.clone(), + }); + } + } + rows +} + pub fn collect_sidebar_nav_items( projects: &[ProjectWithProfiles], groups: &[ProjectGroup], @@ -854,6 +928,53 @@ mod tests { assert!(!leftover_project_row_active(false, false)); } + #[test] + fn leftover_dialog_chrome_matches_inventory() { + assert_eq!(leftover_dialog_width(DialogKind::CreateProject), 384.0); + assert_eq!(leftover_dialog_width(DialogKind::CreateProfile), 384.0); + assert_eq!(leftover_dialog_width(DialogKind::ProjectSettings), 512.0); + assert_eq!(leftover_dialog_width(DialogKind::SwitchBranch), 448.0); + assert_eq!(leftover_dialog_width(DialogKind::DebugLog), 512.0); + assert_eq!(leftover_dialog_width(DialogKind::ReviewQueue), 896.0); + assert!(leftover_rename_disabled("", "App")); + assert!(leftover_rename_disabled("App", "App")); + assert!(leftover_rename_disabled(" App ", "App")); + assert!(!leftover_rename_disabled("Next", "App")); + assert_eq!(leftover_delete_profile_warning(&[]), None); + assert_eq!( + leftover_delete_profile_warning(&["local".into(), "ahead".into()]), + Some("local ahead".into()) + ); + assert!(!leftover_new_terminal_split(true, false)); + assert!(leftover_new_terminal_split(true, true)); + assert!(!leftover_new_terminal_split(false, true)); + } + + #[test] + fn leftover_template_dropdown_matches_inventory() { + assert_eq!( + leftover_template_rows(&[], &[], true), + vec![LeftoverTemplateRow::EmptyTitle, LeftoverTemplateRow::EmptyHint] + ); + assert!(leftover_template_rows(&[], &[], false).is_empty()); + assert_eq!( + leftover_template_rows(&[("Dev".into(), "apps/web".into())], &["zsh".into()], true), + vec![ + LeftoverTemplateRow::ProjectHeader, + LeftoverTemplateRow::Project { + index: 0, + name: "Dev".into(), + cwd: "apps/web".into(), + }, + LeftoverTemplateRow::GlobalHeader, + LeftoverTemplateRow::Global { + index: 0, + name: "zsh".into(), + }, + ] + ); + } + #[test] fn project_group_menu_rows_match_inventory() { assert_eq!( diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index bf130514..3d295392 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -7,7 +7,10 @@ use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::backend; -use crate::state::{project_group_menu_rows, ContextMenu, DialogKind, GroupMenuRow}; +use crate::state::{ + leftover_dialog_width, leftover_rename_disabled, leftover_template_rows, project_group_menu_rows, ContextMenu, + DialogKind, GroupMenuRow, LeftoverTemplateRow, +}; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { div() @@ -27,7 +30,7 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> let title = match kind { DialogKind::CreateProject => app.t("createProject"), DialogKind::DeleteProject => app.t("deleteProject"), - DialogKind::RenameProject => app.t("rename"), + DialogKind::RenameProject => app.t("renameProject"), DialogKind::ProjectSettings => app.t("projectSettings"), DialogKind::CreateProfile => app.t("createProfile"), DialogKind::DeleteProfile => app.t("deleteProfile"), @@ -61,22 +64,16 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> .child( v_flex() .id("dialog-panel") - .w(px(if matches!(kind, DialogKind::ReviewQueue) { - 720. - } else if matches!(kind, DialogKind::SwitchBranch) { - 448. - } else if matches!(kind, DialogKind::ProjectSettings) { - 560. - } else if matches!(kind, DialogKind::DebugLog) { - 512. + .w(px(leftover_dialog_width(kind))) + .max_h(px(if matches!(kind, DialogKind::DebugLog) { + 630. } else { - 380. + 640. })) - .max_h(px(if matches!(kind, DialogKind::DebugLog) { 630. } else { 640. })) .p_4() - .gap_3() + .gap_4() .rounded_xl() - .bg(theme.background) + .bg(theme.popover) .border_1() .border_color(theme.border) .shadow_lg() @@ -89,17 +86,23 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> .gap_2() .items_center() .when(kind == DialogKind::CreateProject, |el| { - el.child(Icon::new(IconName::Folder).w(px(18.))) + el.child(crate::ui::leftover_folder_plus_mark(16., 9.)) + }) + .when(kind == DialogKind::CreateProfile, |el| { + el.child(crate::ui::leftover_branch_glyph(theme.muted_foreground)) + }) + .when(kind == DialogKind::RenameProject, |el| { + el.child(crate::ui::leftover_pencil_glyph(theme.muted_foreground)) }) .when(kind == DialogKind::ProjectSettings, |el| { - el.child(Icon::new(IconName::Settings).w(px(18.))) + el.child(Icon::new(IconName::Settings).w(px(16.))) }) .when( kind == DialogKind::DeleteProfile || kind == DialogKind::DeleteProject, - |el| el.child(Icon::new(IconName::TriangleAlert).w(px(18.))), + |el| el.child(Icon::new(IconName::Delete).w(px(16.))), ) .when(kind == DialogKind::ReviewQueue, |el| { - el.child(Icon::new(IconName::Inbox).w(px(18.))) + el.child(Icon::new(IconName::Inbox).w(px(16.))) }) .when(kind == DialogKind::SwitchBranch, |el| { el.child(crate::ui::leftover_branch_glyph(theme.muted_foreground)) @@ -141,33 +144,51 @@ fn dialog_body( app.t("createProjectHintFolderNamed") }; v_flex() - .gap_3() + .gap(px(20.)) .child(if let Some(folder) = folder.clone() { v_flex() - .gap_1() + .gap(px(6.)) .child( h_flex() .justify_between() - .child(div().text_sm().child(app.t("folder"))) - .child(Button::new("rechoose").xsmall().label(app.t("chooseFolder")).on_click({ - let view = view.clone(); - move |_, window, cx| { - view.update(cx, |app, cx| { - if let Some(p) = backend::pick_folder() { - app.apply_picked_folder(p, window, cx); + .gap_3() + .child( + div() + .text_xs() + .font_medium() + .text_color(theme.muted_foreground) + .child(app.t("folder")), + ) + .child( + Button::new("rechoose") + .outline() + .xsmall() + .label(format!("✎ {}", app.t("chooseFolder"))) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + if let Some(p) = backend::pick_folder() { + app.apply_picked_folder(p, window, cx); + } + cx.notify(); + }); } - cx.notify(); - }); - } - })), + }), + ), ) .child( div() - .p_2() + .px_3() + .py_2() .rounded_md() + .border_1() + .border_color(theme.border) .bg(theme.muted) .font_family("monospace") - .text_xs() + .text_sm() + .whitespace_nowrap() + .overflow_hidden() .child(folder), ) .into_any_element() @@ -175,7 +196,8 @@ fn dialog_body( div() .id("choose-folder-big") .w_full() - .h(px(88.)) + .px_4() + .py(px(24.)) .rounded_lg() .border_1() .border_dashed() @@ -197,8 +219,17 @@ fn dialog_body( }); } }) - .child(Icon::new(IconName::Folder).w(px(24.))) - .child(div().text_sm().child(app.t("chooseFolder"))) + .child( + Icon::new(IconName::Folder) + .w(px(24.)) + .text_color(theme.muted_foreground), + ) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("chooseFolder")), + ) .into_any_element() }) .child(div().text_sm().child(app.t("projectName"))) @@ -221,8 +252,7 @@ fn dialog_body( Button::new("ps-scripts") .small() .selected(app.data.overlay.project_settings_tab == 0) - .icon(IconName::ALargeSmall) - .label(app.t("scripts")) + .label(format!(" {}", app.t("scripts"))) .on_click({ let view = view.clone(); move |_, _, cx| { @@ -404,27 +434,31 @@ fn dialog_body( .child(Input::new(&app.inputs.profile_branch)) .into_any_element(), DialogKind::DeleteProfile => v_flex() - .gap_2() + .gap_3() .child(div().text_sm().child(app.t("confirmDeleteProfile"))) .when(app.data.overlay.dialog_busy, |el| { el.child( h_flex() .gap_2() .items_center() + .text_color(theme.muted_foreground) .child(Spinner::new().with_size(gpui_component::Size::Small)) - .child(div().text_xs().child(app.t("deleteProfileCheckingGitStatus"))), + .child(div().text_sm().child(app.t("deleteProfileCheckingGitStatus"))), ) }) .when_some(app.data.overlay.delete_warning.clone(), |el, warn| { - el.child( - div() - .p_2() - .rounded_md() - .bg(theme.warning) - .child(div().font_semibold().child(app.t("deleteProfileGitWarningTitle"))) - .child(div().text_sm().child(warn)), - ) + el.child(leftover_alert(app.t("deleteProfileGitWarningTitle"), warn, &theme)) }) + .when( + app.data.overlay.delete_check_failed && !app.data.overlay.dialog_busy, + |el| { + el.child(leftover_alert( + app.t("deleteProfileGitCheckFailedTitle"), + app.t("deleteProfileGitCheckFailedDescription"), + &theme, + )) + }, + ) .into_any_element(), DialogKind::CloseUnsaved => div() .text_sm() @@ -683,6 +717,20 @@ fn review_queue_body(app: &AppView, muted: gpui::Hsla, cx: &mut Context }) } +fn leftover_alert(title: String, body: String, theme: &gpui_component::Theme) -> impl IntoElement { + v_flex() + .w_full() + .gap(px(2.)) + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.background) + .px(px(10.)) + .py_2() + .child(div().font_medium().text_sm().child(title)) + .child(div().text_sm().text_color(theme.muted_foreground).child(body)) +} + fn leftover_chip(text: &str, fg: gpui::Hsla, bg: gpui::Hsla, border: gpui::Hsla) -> impl IntoElement { div() .h(px(16.)) @@ -817,30 +865,46 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> DialogKind::SwitchBranch | DialogKind::DebugLog | DialogKind::ChooseFile | DialogKind::ReviewQueue ); let review_empty = app.data.overlay.review_comments.is_empty(); + let rename_init = app + .data + .overlay + .dialog_project + .as_ref() + .and_then(|id| app.data.projects.iter().find(|p| p.id == *id)) + .map(|p| p.name.clone()) + .unwrap_or_default(); + let rename_disabled = kind == DialogKind::RenameProject + && leftover_rename_disabled(&app.inputs.rename.read(cx).value(), &rename_init); h_flex() .justify_end() .gap_2() .when( - kind != DialogKind::ReviewQueue - && kind != DialogKind::SwitchBranch - && kind != DialogKind::DebugLog, + kind != DialogKind::ReviewQueue && kind != DialogKind::SwitchBranch && kind != DialogKind::DebugLog, |el| { - el.child(Button::new("dlg-cancel").small().label(app.t("cancel")).on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.dialog = None; - cx.notify(); - }); - } - })) + el.child( + Button::new("dlg-cancel") + .outline() + .small() + .label(app.t("cancel")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.dialog = None; + cx.notify(); + }); + } + }), + ) }, ) .when(kind == DialogKind::ReviewQueue, |el| { el.child( Button::new("dlg-copy") + .outline() .small() + .icon(IconName::Copy) .label("Copy".to_string()) .disabled(review_empty) .on_click({ @@ -857,6 +921,7 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> Button::new("dlg-copy-clear") .small() .danger() + .icon(IconName::Copy) .label("Copy and clear all".to_string()) .disabled(review_empty) .on_click({ @@ -881,8 +946,9 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> .when(!danger, |b| b.primary()) .label(ok) .disabled( - (matches!(kind, DialogKind::CreateProject) - && (app.data.overlay.dialog_folder.is_none() || app.data.overlay.dialog_busy)) + rename_disabled + || (matches!(kind, DialogKind::CreateProject) + && (app.data.overlay.dialog_folder.is_none() || app.data.overlay.dialog_busy)) || (app.data.overlay.dialog_busy && matches!( kind, @@ -1230,38 +1296,49 @@ fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec { MenuAction::CopyAbs, ), ], - ContextMenu::NewTerminal => { - let mut items = vec![item("new-term", app.t("newTerminal"), MenuAction::NewTerm)]; - let project = app - .data + ContextMenu::NewTerminal => leftover_template_rows( + &app.data .current_ws() - .map(|w| w.config.terminal_templates.clone()) - .unwrap_or_default(); - if !project.is_empty() { - items.push(header("proj-templates", app.t("projectTerminalTemplates"))); - for (i, t) in project.iter().enumerate() { - let label = if t.cwd.is_empty() { - t.name.clone() - } else { - format!("{name} — {cwd}", name = t.name, cwd = t.cwd) - }; - items.push(item(format!("pt-{i}"), label, MenuAction::ProjectTemplate(i))); - } - } - if !app.data.prefs.templates.is_empty() { - items.push(header("global-templates", app.t("globalTerminalTemplates"))); - for (i, t) in app.data.prefs.templates.iter().enumerate() { - items.push(item(format!("gt-{i}"), t.name.clone(), MenuAction::Template(i))); - } - } - if project.is_empty() && app.data.prefs.templates.is_empty() { - items.push(header("no-tpl", app.t("noTerminalTemplates"))); + .map(|w| { + w.config + .terminal_templates + .iter() + .map(|t| (t.name.clone(), t.cwd.clone())) + .collect::>() + }) + .unwrap_or_default(), + &app.data + .prefs + .templates + .iter() + .map(|t| t.name.clone()) + .collect::>(), + true, + ) + .into_iter() + .map(|row| match row { + LeftoverTemplateRow::EmptyTitle => header("no-tpl", app.t("noTerminalTemplates")), + LeftoverTemplateRow::EmptyHint => { let mut hint = item("no-tpl-hint", app.t("noTemplatesDropdownHint"), MenuAction::Header); hint.muted = true; - items.push(hint); + hint } - items - } + LeftoverTemplateRow::ProjectHeader => header("proj-templates", app.t("projectTerminalTemplates")), + LeftoverTemplateRow::Project { index, name, cwd } => item( + format!("pt-{index}"), + if cwd.is_empty() { + name + } else { + format!("{name} — {cwd}") + }, + MenuAction::ProjectTemplate(index), + ), + LeftoverTemplateRow::GlobalHeader => header("global-templates", app.t("globalTerminalTemplates")), + LeftoverTemplateRow::Global { index, name } => { + item(format!("gt-{index}"), name, MenuAction::Template(index)) + } + }) + .collect(), } } diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs index d4913f36..e8934810 100644 --- a/src-gpui/src/ui/mod.rs +++ b/src-gpui/src/ui/mod.rs @@ -1,4 +1,5 @@ -use gpui::{div, prelude::*, AnyView, App, Context, SharedString, Window}; +use gpui::{div, prelude::*, px, AnyView, App, Context, SharedString, Window}; +use gpui_component::{Icon, IconName}; pub fn eid(s: impl Into) -> SharedString { SharedString::from(s.into()) @@ -8,6 +9,33 @@ pub fn leftover_branch_glyph(color: gpui::Hsla) -> gpui::Div { div().text_color(color).child("⎇") } +pub fn leftover_pencil_glyph(color: gpui::Hsla) -> gpui::Div { + div().text_color(color).child("✎") +} + +pub fn leftover_folder_plus_mark(folder: f32, plus: f32) -> gpui::Div { + div() + .relative() + .size(px(folder)) + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .child(Icon::new(IconName::Folder).w(px(folder))) + .child( + div() + .absolute() + .bottom(px(-2.)) + .right(px(-3.)) + .size(px(plus)) + .rounded_full() + .flex() + .items_center() + .justify_center() + .child(Icon::new(IconName::Plus).w(px((plus * 0.75).max(8.)))), + ) +} + pub fn tip(text: impl Into) -> impl Fn(&mut Window, &mut App) -> AnyView + 'static { let text = text.into(); move |window, cx| gpui_component::tooltip::Tooltip::new(text.clone()).build(window, cx) diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index 5eb18398..ae78a8ac 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -5,7 +5,10 @@ use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, Style use gpui_component::{Disableable, Selectable}; use crate::app::AppView; -use crate::state::{AgentKind, DialogKind, SidebarMode, UnifiedTab}; +use crate::state::{ + leftover_new_terminal_split, leftover_template_rows, AgentKind, DialogKind, LeftoverTemplateRow, SidebarMode, + UnifiedTab, +}; use crate::ui::{file_tree, file_viewer, git, leftover_branch_glyph, notes, sidebar, terminal, tip}; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -498,7 +501,7 @@ fn empty_cta(app: &AppView, cx: &mut Context) -> impl IntoElement { .text_color(theme.muted_foreground) .child(app.t("noTerminalsOpenDescription")), ) - .child(new_terminal_control(app, view, false)) + .child(new_terminal_control(app, view.clone(), true)) } fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { @@ -656,23 +659,191 @@ fn tab_bar(app: &AppView, cx: &mut Context) -> impl IntoElement { } }) })) - .child(new_terminal_control(app, view, false)) + .child(leftover_new_terminal_tab(app, view, cx)) .into_any_element() } -fn new_terminal_control(app: &AppView, view: gpui::Entity, force_split: bool) -> impl IntoElement { +fn leftover_new_terminal_tab( + app: &AppView, + view: gpui::Entity, + cx: &mut Context, +) -> impl IntoElement { + let theme = cx.theme().clone(); + let hover = app.data.overlay.new_terminal_hover; + let project: Vec<(String, String)> = app + .data + .current_ws() + .map(|w| { + w.config + .terminal_templates + .iter() + .map(|t| (t.name.clone(), t.cwd.clone())) + .collect() + }) + .unwrap_or_default(); + let global: Vec = app.data.prefs.templates.iter().map(|t| t.name.clone()).collect(); + let rows = leftover_template_rows(&project, &global, true); + div() + .id("new-term-tab") + .relative() + .ml_2() + .max_w(px(224.)) + .on_hover({ + let view = view.clone(); + move |hovered, _, cx| { + view.update(cx, |app, cx| { + if app.data.overlay.new_terminal_hover != *hovered { + app.data.overlay.new_terminal_hover = *hovered; + cx.notify(); + } + }); + } + }) + .child( + Button::new("new-term") + .ghost() + .small() + .icon(IconName::Plus) + .label(app.t("newTerminal")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.create_terminal(&app.t("newTerminal"), "", Vec::new()); + app.data.overlay.new_terminal_hover = false; + cx.notify(); + }); + } + }), + ) + .when(hover, |el| { + el.child(leftover_template_dropdown(app, &rows, view.clone(), theme.clone())) + }) +} + +fn leftover_template_dropdown( + app: &AppView, + rows: &[LeftoverTemplateRow], + view: gpui::Entity, + theme: gpui_component::Theme, +) -> impl IntoElement { + let border = theme.border; + let popover = theme.popover; + let muted = theme.muted; + let muted_fg = theme.muted_foreground; + v_flex() + .id("new-term-hover-menu") + .absolute() + .top(px(36.)) + .left_0() + .min_w(px(224.)) + .p_1() + .gap_1() + .rounded_lg() + .border_1() + .border_color(border) + .bg(popover) + .shadow_md() + .children(rows.iter().cloned().map(|row| { + match row { + LeftoverTemplateRow::EmptyTitle => div() + .px_2() + .py_1() + .text_sm() + .text_color(muted_fg) + .child(app.t("noTerminalTemplates")) + .into_any_element(), + LeftoverTemplateRow::EmptyHint => div() + .px_2() + .py_1() + .text_xs() + .text_color(muted_fg) + .child(app.t("noTemplatesDropdownHint")) + .into_any_element(), + LeftoverTemplateRow::ProjectHeader => div() + .px_2() + .py_1() + .text_xs() + .font_medium() + .text_color(muted_fg) + .child(app.t("projectTerminalTemplates")) + .into_any_element(), + LeftoverTemplateRow::GlobalHeader => div() + .px_2() + .py_1() + .text_xs() + .font_medium() + .text_color(muted_fg) + .child(app.t("globalTerminalTemplates")) + .into_any_element(), + LeftoverTemplateRow::Project { index, name, cwd } => div() + .id(crate::ui::eid(format!("hover-pt-{index}"))) + .px_2() + .py_2() + .rounded_md() + .hover(|el| el.bg(muted)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(t) = app + .data + .current_ws() + .and_then(|w| w.config.terminal_templates.get(index).cloned()) + { + app.create_terminal(&t.name, &t.cwd, t.commands); + } + app.data.overlay.new_terminal_hover = false; + cx.notify(); + }); + } + }) + .child( + v_flex() + .gap(px(2.)) + .child(div().text_sm().child(name)) + .when(!cwd.is_empty(), |el| { + el.child(div().text_xs().text_color(muted_fg).child(cwd)) + }), + ) + .into_any_element(), + LeftoverTemplateRow::Global { index, name } => div() + .id(crate::ui::eid(format!("hover-gt-{index}"))) + .px_2() + .py_2() + .rounded_md() + .hover(|el| el.bg(muted)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + if let Some(t) = app.data.prefs.templates.get(index).cloned() { + app.create_terminal(&t.name, &t.cwd, t.commands); + } + app.data.overlay.new_terminal_hover = false; + cx.notify(); + }); + } + }) + .child(div().text_sm().child(name)) + .into_any_element(), + } + })) +} + +fn new_terminal_control(app: &AppView, view: gpui::Entity, empty_cta: bool) -> impl IntoElement { let has_templates = !app.data.prefs.templates.is_empty() || app .data .current_ws() .map(|w| !w.config.terminal_templates.is_empty()) .unwrap_or(false); - if force_split || has_templates { + if leftover_new_terminal_split(has_templates, empty_cta) { h_flex() .gap_0() .child( Button::new("new-term") - .ghost() + .primary() .small() .icon(IconName::Plus) .label(app.t("newTerminal")) @@ -688,7 +859,7 @@ fn new_terminal_control(app: &AppView, view: gpui::Entity, force_split: ) .child( Button::new("new-term-split") - .ghost() + .primary() .small() .icon(IconName::ChevronDown) .tooltip("Choose template".to_string()) @@ -706,7 +877,7 @@ fn new_terminal_control(app: &AppView, view: gpui::Entity, force_split: .into_any_element() } else { Button::new("new-term") - .ghost() + .primary() .small() .icon(IconName::Plus) .label(app.t("newTerminal")) From 0f5223793676d75d006fbdafe8a4a5c8b3ebf9bb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 09:10:01 +0000 Subject: [PATCH 43/50] Match leftover file viewer, link dialogs, and Windows chrome. Binary/archive previews now use leftover headers without an extra Open button, the text editor no longer shows a leftover-inaccurate Save toolbar, and Open Link / Choose File follow leftover title icons, rows, and the leftover Open-with split. Home uses leftover house mark; onboarding sits next to Add Project; Windows caption hover/active matches leftover. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 1 + src-gpui/src/state.rs | 3 + src-gpui/src/ui/dialogs.rs | 152 ++++++++++---- src-gpui/src/ui/file_viewer.rs | 351 ++++++++++++++++----------------- src-gpui/src/ui/mod.rs | 4 + src-gpui/src/ui/shell.rs | 21 +- src-gpui/src/ui/sidebar.rs | 33 +++- 7 files changed, 325 insertions(+), 240 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index d893f3e2..3d421008 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -2321,6 +2321,7 @@ impl AppView { return; } self.data.overlay.dialog_url = Some(url.clone()); + self.data.overlay.open_link_menu = false; self.data.overlay.dialog = Some(DialogKind::OpenLink); } crate::detector::Clickable::Path(path) => { diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index cdf3d865..0e45300f 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -635,6 +635,7 @@ pub struct OverlayState { pub md_preview: bool, pub new_terminal_hover: bool, pub delete_check_failed: bool, + pub open_link_menu: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -775,6 +776,7 @@ pub fn leftover_dialog_width(kind: DialogKind) -> f32 { DialogKind::SwitchBranch => 448.0, DialogKind::ProjectSettings => 512.0, DialogKind::DebugLog => 512.0, + DialogKind::ChooseFile => 576.0, _ => 384.0, } } @@ -936,6 +938,7 @@ mod tests { assert_eq!(leftover_dialog_width(DialogKind::SwitchBranch), 448.0); assert_eq!(leftover_dialog_width(DialogKind::DebugLog), 512.0); assert_eq!(leftover_dialog_width(DialogKind::ReviewQueue), 896.0); + assert_eq!(leftover_dialog_width(DialogKind::ChooseFile), 576.0); assert!(leftover_rename_disabled("", "App")); assert!(leftover_rename_disabled("App", "App")); assert!(leftover_rename_disabled(" App ", "App")); diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index 3d295392..eeda3279 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -107,6 +107,15 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> .when(kind == DialogKind::SwitchBranch, |el| { el.child(crate::ui::leftover_branch_glyph(theme.muted_foreground)) }) + .when(kind == DialogKind::CloseUnsaved, |el| { + el.child(Icon::new(IconName::TriangleAlert).w(px(16.))) + }) + .when(kind == DialogKind::OpenLink, |el| { + el.child(Icon::new(IconName::ExternalLink).w(px(16.))) + }) + .when(kind == DialogKind::ChooseFile, |el| { + el.child(Icon::new(IconName::File).w(px(16.))) + }) .child(div().font_semibold().child(title)), ) .child(Button::new("dlg-x").ghost().xsmall().icon(IconName::Close).on_click({ @@ -502,73 +511,81 @@ fn dialog_body( .into_any_element() } DialogKind::OpenLink => v_flex() - .gap_2() + .gap_1() .child(div().text_sm().child(app.t("terminalOpenLinkConfirmDescription"))) .child( div() - .text_xs() - .text_color(theme.muted_foreground) + .mt_3() .font_family("monospace") + .text_sm() + .text_color(theme.muted_foreground) .child(app.t("terminalOpenLinkUrlLabel")), ) .child( div() .font_family("monospace") - .text_xs() + .text_sm() .child(app.data.overlay.dialog_url.clone().unwrap_or_default()), ) + .into_any_element(), + DialogKind::ChooseFile => v_flex() + .gap_2() .child( div() - .text_xs() + .text_sm() .text_color(theme.muted_foreground) - .child(app.t("browserOpenWith")), + .child(app.t("terminalChooseFilePathDescription")), ) .child( - h_flex() - .gap_1() - .flex_wrap() - .children(crate::platform::installed_browsers().into_iter().map(|browser| { - let cmd = browser.command; - Button::new(crate::ui::eid(format!("open-with-{cmd}"))) - .xsmall() - .label(browser.id) + div() + .id("choose-file-list") + .max_h(px(450.)) + .overflow_y_scroll() + .rounded_md() + .border_1() + .border_color(theme.border) + .children(app.data.overlay.fuzzy_files.iter().map(|f| { + let path = f.path.clone(); + h_flex() + .id(crate::ui::eid(format!("fuzzy-{path}"))) + .min_h(px(44.)) + .px_3() + .py_2() + .gap_3() + .border_b_1() + .border_color(theme.border) + .hover(|el| el.bg(theme.muted)) .on_click({ let view = view.clone(); - move |_, _, cx| { + let path = path.clone(); + move |_, window, cx| { view.update(cx, |app, cx| { - app.open_url_with(cmd); + if let Some(pid) = app.data.current_profile.clone() { + app.open_file(&pid, &path, window, cx); + } + app.data.overlay.dialog = None; cx.notify(); }); } }) + .child(crate::ui::file_icons::file_glyph(&f.name, false, false, 16.)) + .child( + v_flex() + .min_w_0() + .flex_1() + .child(div().text_sm().font_medium().child(f.name.clone())) + .child( + div() + .font_family("monospace") + .text_xs() + .text_color(theme.muted_foreground) + .child(f.relative_path.clone()), + ), + ) + .child(Icon::new(IconName::File).w(px(14.))) })), ) .into_any_element(), - DialogKind::ChooseFile => v_flex() - .gap_2() - .child(div().text_sm().child(app.t("terminalChooseFilePathDescription"))) - .children(app.data.overlay.fuzzy_files.iter().map(|f| { - let path = f.path.clone(); - div() - .id(crate::ui::eid(format!("fuzzy-{path}"))) - .px_2() - .py_1() - .on_click({ - let view = view.clone(); - let path = path.clone(); - move |_, window, cx| { - view.update(cx, |app, cx| { - if let Some(pid) = app.data.current_profile.clone() { - app.open_file(&pid, &path, window, cx); - } - app.data.overlay.dialog = None; - cx.notify(); - }); - } - }) - .child(f.name.clone()) - })) - .into_any_element(), DialogKind::EditTemplate => v_flex() .gap_2() .child(Input::new(&app.inputs.template_name)) @@ -837,6 +854,7 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> return div().id("no-debug-footer").into_any_element(); } let view = cx.entity(); + let theme = cx.theme().clone(); let danger = matches!( kind, DialogKind::DeleteProject | DialogKind::DeleteProfile | DialogKind::CloseUnsaved @@ -892,6 +910,7 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> move |_, _, cx| { view.update(cx, |app, cx| { app.data.overlay.dialog = None; + app.data.overlay.open_link_menu = false; cx.notify(); }); } @@ -991,6 +1010,57 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> } }), ) + .when(kind == DialogKind::OpenLink, |el| { + el.child( + Button::new("dlg-open-with") + .primary() + .small() + .icon(IconName::ChevronDown) + .tooltip(app.t("browserOpenWith")) + .disabled(crate::platform::installed_browsers().is_empty()) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.open_link_menu = !app.data.overlay.open_link_menu; + cx.notify(); + }); + } + }), + ) + .when(app.data.overlay.open_link_menu, |el| { + el.child( + v_flex() + .id("open-link-browsers") + .min_w(px(208.)) + .p_1() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .children(crate::platform::installed_browsers().into_iter().map(|browser| { + let cmd = browser.command; + div() + .id(crate::ui::eid(format!("open-with-{cmd}"))) + .px_2() + .py_1() + .rounded_md() + .hover(|row| row.bg(theme.muted)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.open_url_with(cmd); + app.data.overlay.open_link_menu = false; + cx.notify(); + }); + } + }) + .child(div().text_sm().child(browser.id)) + })), + ) + }) + }) }) .into_any_element() } diff --git a/src-gpui/src/ui/file_viewer.rs b/src-gpui/src/ui/file_viewer.rs index 0f40543d..b555c57b 100644 --- a/src-gpui/src/ui/file_viewer.rs +++ b/src-gpui/src/ui/file_viewer.rs @@ -5,7 +5,7 @@ use gpui::{div, img, prelude::*, px, rgb, Context, Window}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; use gpui_component::text::TextView; -use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable}; +use gpui_component::{h_flex, v_flex, ActiveTheme, IconName, Sizable, StyledExt}; use crate::app::AppView; use crate::backend; @@ -87,93 +87,78 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .size_full() .font(markdown::editor_font(app.data.prefs.font_family.clone())) .text_size(px(app.data.prefs.font_size)) - .child( - h_flex() - .w_full() - .px_2() - .py_1() - .gap_2() - .border_b_1() - .border_color(theme.border) - .when(app.data.overlay.file_search_open, |el| { - el.child(div().flex_1().child(Input::new(&app.inputs.file_search))) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(if q.is_empty() { - app.t("fileViewerFindInFile") - } else if hit_count == 0 { - app.t("terminalSearchNoResults") - } else { - format!("{hit_ix}/{hit_count}") - }), - ) - .child( - Button::new("file-find-prev") - .ghost() - .xsmall() - .icon(IconName::ChevronUp) - .tooltip(app.t("fileViewerPreviousMatch")) - .on_click({ - let view = view.clone(); - move |_, window, cx| { - view.update(cx, |app, cx| { - app.cycle_file_search(window, cx, false); - cx.notify(); - }); - } - }), - ) - .child( - Button::new("file-find-next") - .ghost() - .xsmall() - .icon(IconName::ChevronDown) - .tooltip(app.t("fileViewerNextMatch")) - .on_click({ - let view = view.clone(); - move |_, window, cx| { - view.update(cx, |app, cx| { - app.cycle_file_search(window, cx, true); - cx.notify(); - }); - } - }), - ) - .child( - Button::new("file-find-close") - .ghost() - .xsmall() - .icon(IconName::Close) - .tooltip(app.t("fileViewerCloseFileSearch")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.file_search_open = false; - cx.notify(); - }); - } - }), - ) - }) - .child( - Button::new("file-save") - .xsmall() - .primary() - .label(app.t("save")) - .on_click({ - let view = view.clone(); - move |_, window, cx| { - view.update(cx, |app, cx| { - app.save_active_file(window, cx); - cx.notify(); - }); - } - }), - ), - ) + .when(app.data.overlay.file_search_open, |el| { + el.child( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_2() + .border_b_1() + .border_color(theme.border) + .child(div().flex_1().child(Input::new(&app.inputs.file_search))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(if q.is_empty() { + app.t("fileViewerFindInFile") + } else if hit_count == 0 { + app.t("terminalSearchNoResults") + } else { + format!("{hit_ix}/{hit_count}") + }), + ) + .child( + Button::new("file-find-prev") + .ghost() + .xsmall() + .icon(IconName::ChevronUp) + .tooltip(app.t("fileViewerPreviousMatch")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.cycle_file_search(window, cx, false); + cx.notify(); + }); + } + }), + ) + .child( + Button::new("file-find-next") + .ghost() + .xsmall() + .icon(IconName::ChevronDown) + .tooltip(app.t("fileViewerNextMatch")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.cycle_file_search(window, cx, true); + cx.notify(); + }); + } + }), + ) + .child( + Button::new("file-find-close") + .ghost() + .xsmall() + .icon(IconName::Close) + .tooltip(app.t("fileViewerCloseFileSearch")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.data.overlay.file_search_open = false; + cx.notify(); + }); + } + }), + ), + ) + }) .child( div() .id("text-editor") @@ -185,44 +170,83 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .into_any_element() } +pub fn leftover_preview_label(kind: &str) -> &'static str { + if kind == "office-pdf" { + "Office Preview" + } else { + "Preview" + } +} + +pub fn leftover_archive_counts(entries: &[(String, String)]) -> (usize, usize) { + let files = entries.iter().filter(|(_, kind)| kind == "file").count(); + (files, entries.len().saturating_sub(files)) +} + +fn leftover_preview_header(theme: &gpui_component::Theme, title: &str, right: String) -> impl IntoElement { + h_flex() + .w_full() + .min_h(px(36.)) + .px_3() + .gap_3() + .border_b_1() + .border_color(theme.border) + .bg(theme.muted) + .justify_between() + .child(div().min_w_0().text_sm().font_medium().child(title.to_string())) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .whitespace_nowrap() + .child(right), + ) +} + fn preview_pane(_app: &AppView, file: &crate::state::OpenFileTab, cx: &mut Context) -> impl IntoElement { let theme = cx.theme().clone(); - let view = cx.entity(); - let path = file.path.clone(); + if file.preview_kind == "archive" && !file.archive_entries.is_empty() { + let (files, folders) = leftover_archive_counts(&file.archive_entries); + return v_flex() + .id("archive-preview") + .size_full() + .overflow_hidden() + .child(leftover_preview_header( + &theme, + &file.title, + format!("{files} files / {folders} folders"), + )) + .child( + v_flex() + .flex_1() + .min_h_0() + .min_w_0() + .overflow_hidden() + .px(px(6.)) + .py_1() + .children(file.archive_entries.iter().map(|(path, kind)| { + let is_dir = kind == "dir"; + let depth = path.matches('/').count(); + h_flex() + .gap_1() + .pl(px(4. + 12. * depth as f32)) + .child(crate::ui::file_icons::file_glyph(path, is_dir, false, 13.)) + .child(div().text_size(px(13.)).child(path.clone())) + })), + ) + .into_any_element(); + } v_flex() .id("binary-preview") .size_full() - .child( - h_flex() - .w_full() - .min_h(px(36.)) - .px_3() - .bg(theme.muted) - .justify_between() - .child(div().text_sm().child(file.title.clone())) - .child( - h_flex() - .gap_2() - .child(div().text_xs().child( - if backend::is_document_preview(&file.preview_kind, &file.path) - && !backend::is_pdf(&file.path) - && file.preview_kind != "pdf" - { - "Office Preview".to_string() - } else { - "Preview".to_string() - }, - )) - .child(Button::new("open-external").xsmall().label("Open").on_click({ - let view = view.clone(); - let path = path.clone(); - move |_, _, cx| { - view.update(cx, |app, _| app.open_external(&path)); - } - })), - ), - ) + .overflow_hidden() + .child(leftover_preview_header( + &theme, + &file.title, + leftover_preview_label(&file.preview_kind).to_string(), + )) .child(preview_body(file, cx)) + .into_any_element() } fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> impl IntoElement { @@ -265,65 +289,16 @@ fn preview_body(file: &crate::state::OpenFileTab, cx: &mut Context) -> .child(img(png).id("pdf-preview-img").max_w_full().max_h_full()) .into_any_element(); } - let open_path = path.clone(); - return v_flex() - .id("document-preview") - .size_full() - .bg(rgb(0xffffff)) - .items_center() - .justify_center() - .gap_2() - .child(div().text_sm().text_color(rgb(0x1f2328)).child(file.title.clone())) - .child(div().text_xs().text_color(rgb(0x656d76)).child( - if file.preview_kind == "office-pdf" || backend::is_office(&file.path) { - "Office Preview".to_string() - } else { - "Preview".to_string() - }, - )) - .child(Button::new("doc-open").small().label("Open").on_click(move |_, _, _| { - let _ = open::that(&open_path); - })) - .into_any_element(); - } - if file.preview_kind == "archive" && !file.archive_entries.is_empty() { - let files = file.archive_entries.iter().filter(|(_, k)| k != "dir").count(); - let folders = file.archive_entries.iter().filter(|(_, k)| k == "dir").count(); - return v_flex() - .id("archive-preview") - .size_full() - .p_3() - .gap_1() - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(format!("{files} files / {folders} folders")), - ) - .children(file.archive_entries.iter().map(|(path, kind)| { - let is_dir = kind == "dir"; - let depth = path.matches('/').count(); - h_flex() - .gap_1() - .pl(px(4. + 12. * depth as f32)) - .child(crate::ui::file_icons::file_glyph(path, is_dir, false, 13.)) - .child(div().text_size(px(13.)).child(path.clone())) - })) - .into_any_element(); } v_flex() .flex_1() .items_center() .justify_center() - .gap_2() .child( div() - .text_color(theme.muted_foreground) - .child(file.preview_kind.clone()), - ) - .child( - div() - .text_xs() + .max_w(px(512.)) + .px_6() + .text_sm() .text_color(theme.muted_foreground) .child(if file.binary_note.is_empty() { "Preview unavailable".to_string() @@ -343,27 +318,22 @@ pub fn line_number_count(text: &str) -> usize { } fn checkerboard() -> impl IntoElement { - let light = rgb(0x2a2e33); - let dark = rgb(0x1b1f23); let cell = px(16.); + let mark = gpui::hsla(0., 0., 0.5, 0.08); v_flex() .id("image-checkerboard") .absolute() .inset_0() .overflow_hidden() .children((0..24).map(move |row| { - h_flex().children((0..40).map(move |col| { - div() - .w(cell) - .h(cell) - .bg(if (row + col) % 2 == 0 { light } else { dark }) - })) + h_flex() + .children((0..40).map(move |col| div().w(cell).h(cell).when((row + col) % 2 == 0, |el| el.bg(mark)))) })) } #[cfg(test)] mod tests { - use super::line_number_count; + use super::{leftover_archive_counts, leftover_preview_label, line_number_count}; #[test] fn line_number_count_empty_is_one() { @@ -376,4 +346,23 @@ mod tests { assert_eq!(line_number_count("a\nb"), 2); assert_eq!(line_number_count("a\nb\nc"), 3); } + + #[test] + fn leftover_preview_label_is_office_only_for_office_pdf() { + assert_eq!(leftover_preview_label("office-pdf"), "Office Preview"); + assert_eq!(leftover_preview_label("pdf"), "Preview"); + assert_eq!(leftover_preview_label("image"), "Preview"); + } + + #[test] + fn leftover_archive_counts_match_file_preview_pane() { + assert_eq!( + leftover_archive_counts(&[ + ("a.txt".into(), "file".into()), + ("src/".into(), "dir".into()), + ("src/b.rs".into(), "file".into()), + ]), + (2, 1) + ); + } } diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs index e8934810..9d3eaeb7 100644 --- a/src-gpui/src/ui/mod.rs +++ b/src-gpui/src/ui/mod.rs @@ -13,6 +13,10 @@ pub fn leftover_pencil_glyph(color: gpui::Hsla) -> gpui::Div { div().text_color(color).child("✎") } +pub fn leftover_house_glyph(color: gpui::Hsla) -> gpui::Div { + div().text_color(color).child("⌂") +} + pub fn leftover_folder_plus_mark(folder: f32, plus: f32) -> gpui::Div { div() .relative() diff --git a/src-gpui/src/ui/shell.rs b/src-gpui/src/ui/shell.rs index 398908f5..ede45590 100644 --- a/src-gpui/src/ui/shell.rs +++ b/src-gpui/src/ui/shell.rs @@ -206,21 +206,24 @@ fn win_btn( .items_center() .justify_center() .text_xs() + .text_color(gpui::hsla(0., 0., 0.45, 1.)) + .tooltip(crate::ui::tip(tip)) + .on_click(move |_, window, _| on(window)) .hover(|el| { if close { el.bg(rgb(0xc42b1c)).text_color(gpui::white()) } else { - el.bg(gpui::hsla(0., 0., 0.5, 0.12)) + el.bg(gpui::hsla(0., 0., 0.97, 1.)) } }) - .child( - Button::new(crate::ui::eid(format!("{id}-btn"))) - .ghost() - .xsmall() - .label(label) - .tooltip(tip.to_string()) - .on_click(move |_, window, _| on(window)), - ) + .active(|el| { + if close { + el.bg(rgb(0xb32717)).text_color(gpui::white()) + } else { + el.bg(gpui::hsla(0., 0., 0.97, 1.)) + } + }) + .child(label) } #[cfg(test)] diff --git a/src-gpui/src/ui/sidebar.rs b/src-gpui/src/ui/sidebar.rs index bd778801..40331de1 100644 --- a/src-gpui/src/ui/sidebar.rs +++ b/src-gpui/src/ui/sidebar.rs @@ -91,9 +91,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .gap_1() .overflow_hidden() .when(!has_projects, |el| { - el.child(nav_row( - "home-row", - IconName::Inbox, + el.child(leftover_home_row( app.t("home"), app.data.route == Route::Home || app.data.overlay.sidebar_nav == Some(SidebarNavItem::Home), @@ -106,6 +104,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) }); } }, + cx, )) }) .when_some(app.data.sidebar_error.clone(), |el, err| { @@ -502,7 +501,7 @@ fn onboarding_popover(app: &AppView, sidebar_width: f32, cx: &mut Context f32 { + let chrome = if macos_overlay { 52. } else { 28. }; + chrome + 36. + 32. +} + fn has_any_projects(app: &AppView) -> bool { !app.data.projects.is_empty() } @@ -913,15 +917,15 @@ fn project_row( }) } -fn nav_row( - id: &'static str, - icon: IconName, +fn leftover_home_row( label: String, selected: bool, on_click: impl Fn(&mut gpui::App) + 'static, + cx: &mut Context, ) -> impl IntoElement { + let theme = cx.theme().clone(); div() - .id(id) + .id("home-row") .px_2() .py_1() .rounded_md() @@ -930,7 +934,7 @@ fn nav_row( .child( h_flex() .gap_2() - .child(Icon::new(icon).w(px(14.))) + .child(crate::ui::leftover_house_glyph(theme.muted_foreground)) .child(div().text_sm().child(label)), ) } @@ -965,3 +969,14 @@ pub fn agent_dot(status: AgentStatus) -> impl IntoElement { fn _file_name(path: &str) -> String { backend::file_name(path) } + +#[cfg(test)] +mod tests { + use super::leftover_onboarding_top; + + #[test] + fn leftover_onboarding_anchors_right_of_add_project() { + assert_eq!(leftover_onboarding_top(true), 120.0); + assert_eq!(leftover_onboarding_top(false), 96.0); + } +} From 0046a1e22779559dec05f9d41d475c0e05c9664b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 09:22:26 +0000 Subject: [PATCH 44/50] Match leftover topbar, file menu, and settings chrome. Hide empty PR status, show leftover browser names and app tooltips, scroll the git file list, and restore leftover file-tree menu separators plus About copyright. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 16 +++- src-gpui/src/platform.rs | 63 +++++++++++-- src-gpui/src/state.rs | 171 +++++++++++++++++++++++++++++++++++ src-gpui/src/timefmt.rs | 23 +++++ src-gpui/src/ui/dialogs.rs | 119 +++++++++++++++++------- src-gpui/src/ui/file_tree.rs | 39 +------- src-gpui/src/ui/git.rs | 10 +- src-gpui/src/ui/home.rs | 16 +--- src-gpui/src/ui/notes.rs | 13 +-- src-gpui/src/ui/palette.rs | 13 ++- src-gpui/src/ui/settings.rs | 36 +++++++- src-gpui/src/ui/workspace.rs | 123 ++++++++++++++----------- 12 files changed, 471 insertions(+), 171 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 3d421008..73a7d129 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -2211,10 +2211,20 @@ impl AppView { self.data.overlay.palette_query = q.clone(); if q.trim().is_empty() { self.data.overlay.palette_results.clear(); + self.data.overlay.palette_error = None; self.data.overlay.palette_index = 0; return; } - self.data.overlay.palette_results = self.backend.search_files(&profile_id, &q).unwrap_or_default(); + match self.backend.search_files(&profile_id, &q) { + Ok(results) => { + self.data.overlay.palette_results = results; + self.data.overlay.palette_error = None; + } + Err(err) => { + self.data.overlay.palette_results.clear(); + self.data.overlay.palette_error = Some(err.to_string()); + } + } self.data.overlay.palette_index = 0; } @@ -2572,7 +2582,7 @@ impl AppView { "cursor" => "cursor", "windsurf" => "windsurf", "zed" => "zed", - "sublime" | "subl" => "subl", + "sublime" | "subl" | "sublime-text" => "subl", _ => "code", }, "terminal" => match self.data.prefs.terminal_app.as_str() { @@ -2712,6 +2722,7 @@ impl AppView { self.data.overlay.palette_open = false; self.data.overlay.palette_results.clear(); self.data.overlay.palette_query.clear(); + self.data.overlay.palette_error = None; return true; } if let Some(term) = self.data.current_ws_mut().and_then(|w| w.active_terminal_mut()) { @@ -3196,6 +3207,7 @@ impl gpui::Render for AppView { if !this.data.overlay.palette_open { this.data.overlay.palette_results.clear(); this.data.overlay.palette_query.clear(); + this.data.overlay.palette_error = None; } cx.notify(); } diff --git a/src-gpui/src/platform.rs b/src-gpui/src/platform.rs index 238324be..c3bfaa54 100644 --- a/src-gpui/src/platform.rs +++ b/src-gpui/src/platform.rs @@ -3,6 +3,7 @@ use std::sync::OnceLock; #[derive(Clone, Debug, PartialEq, Eq)] pub struct DetectedApp { pub id: &'static str, + pub name: &'static str, pub command: &'static str, } @@ -30,6 +31,11 @@ pub fn command_exists(command: &str) -> bool { ("firefox", "/Applications/Firefox.app"), ("chrome", "/Applications/Google Chrome.app"), ("safari", "/Applications/Safari.app"), + ("edge", "/Applications/Microsoft Edge.app"), + ("brave", "/Applications/Brave Browser.app"), + ("arc", "/Applications/Arc.app"), + ("vivaldi", "/Applications/Vivaldi.app"), + ("orion", "/Applications/Orion.app"), ("github", "/Applications/GitHub Desktop.app"), ]; if let Some((_, path)) = apps.iter().find(|(id, _)| *id == command) { @@ -43,22 +49,27 @@ pub fn installed_editors() -> Vec { [ DetectedApp { id: "vscode", + name: "VS Code", command: "code", }, DetectedApp { id: "cursor", + name: "Cursor", command: "cursor", }, DetectedApp { id: "windsurf", + name: "Windsurf", command: "windsurf", }, DetectedApp { id: "zed", + name: "Zed", command: "zed", }, DetectedApp { - id: "sublime", + id: "sublime-text", + name: "Sublime Text", command: "subl", }, ] @@ -71,18 +82,22 @@ pub fn installed_terminals() -> Vec { [ DetectedApp { id: "ghostty", + name: "Ghostty", command: "ghostty", }, DetectedApp { id: "iterm2", + name: "iTerm2", command: "iterm2", }, DetectedApp { id: "kitty", + name: "Kitty", command: "kitty", }, DetectedApp { id: "warp", + name: "Warp", command: "warp", }, ] @@ -94,20 +109,54 @@ pub fn installed_terminals() -> Vec { pub fn installed_browsers() -> Vec { [ DetectedApp { - id: "firefox", - command: "firefox", + id: "safari", + name: "Safari", + command: "safari", }, DetectedApp { id: "chrome", + name: "Google Chrome", command: "google-chrome", }, DetectedApp { - id: "chromium", - command: "chromium", + id: "chrome-canary", + name: "Google Chrome Canary", + command: "google-chrome-canary", }, DetectedApp { - id: "safari", - command: "safari", + id: "edge", + name: "Microsoft Edge", + command: "microsoft-edge", + }, + DetectedApp { + id: "firefox", + name: "Firefox", + command: "firefox", + }, + DetectedApp { + id: "arc", + name: "Arc", + command: "arc", + }, + DetectedApp { + id: "brave", + name: "Brave Browser", + command: "brave", + }, + DetectedApp { + id: "vivaldi", + name: "Vivaldi", + command: "vivaldi", + }, + DetectedApp { + id: "orion", + name: "Orion", + command: "orion", + }, + DetectedApp { + id: "chromium", + name: "Chromium", + command: "chromium", }, ] .into_iter() diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 0e45300f..aef1f491 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -593,6 +593,7 @@ pub struct OverlayState { pub palette_results: Vec, pub palette_index: usize, pub palette_query: String, + pub palette_error: Option, pub git_diff_open: bool, pub git_diff_tab: GitDiffTab, pub git_diff_mode: DiffPreviewMode, @@ -826,6 +827,131 @@ pub fn leftover_template_rows( rows } +pub fn leftover_browser_name(id: &str) -> &'static str { + match leftover_normalize_app_id(id) { + "safari" => "Safari", + "chrome" => "Google Chrome", + "chrome-canary" => "Google Chrome Canary", + "edge" => "Microsoft Edge", + "firefox" => "Firefox", + "arc" => "Arc", + "brave" => "Brave Browser", + "vivaldi" => "Vivaldi", + "orion" => "Orion", + "chromium" => "Chromium", + _ => "", + } +} + +pub fn leftover_normalize_app_id(id: &str) -> &str { + match id { + "code" | "vscode" => "vscode", + "sublime" | "subl" | "sublime-text" => "sublime-text", + "google-chrome" | "chrome" => "chrome", + other => other, + } +} + +pub const LEFTOVER_EDITOR_APP_IDS: &[&str] = &["vscode", "windsurf", "cursor", "zed", "sublime-text"]; +pub const LEFTOVER_TERMINAL_APP_IDS: &[&str] = &["ghostty", "iterm2", "kitty", "warp"]; + +pub fn leftover_configured_app<'a>(preferred: &str, category: &[&'a str], installed: &[&str]) -> Option<&'a str> { + let preferred = leftover_normalize_app_id(preferred); + if installed.iter().any(|id| leftover_normalize_app_id(id) == preferred) { + return category.iter().copied().find(|id| *id == preferred); + } + category.iter().copied().find(|id| { + installed + .iter() + .any(|installed| leftover_normalize_app_id(installed) == *id) + }) +} + +pub fn leftover_launch_app_i18n(id: &str) -> &'static str { + match leftover_normalize_app_id(id) { + "vscode" => "topbarVscode", + "windsurf" => "topbarWindsurf", + "cursor" => "topbarCursor", + "zed" => "topbarZed", + "sublime-text" => "topbarSublimeText", + "ghostty" => "topbarGhostty", + "iterm2" => "topbarIterm2", + "kitty" => "topbarKitty", + "warp" => "topbarWarp", + "github-desktop" => "topbarGithubDesktop", + _ => "topbarEditor", + } +} + +pub fn leftover_topbar_app_tooltip(kind: &str, app_label: &str) -> String { + format!("{kind} · {app_label}") +} + +pub fn leftover_pr_visible(has_pr: bool) -> bool { + has_pr +} + +pub fn leftover_pr_label(number: u32, state_label: &str) -> String { + format!("#{number} {state_label}") +} + +pub fn leftover_about_copyright(year: i32) -> String { + format!("© {year} AkaraChen") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LeftoverFileMenuFlags { + pub can_open: bool, + pub can_open_default: bool, + pub can_reveal: bool, + pub can_rename: bool, + pub can_delete: bool, +} + +pub fn leftover_file_menu_flags(is_file: bool, exists: bool) -> LeftoverFileMenuFlags { + LeftoverFileMenuFlags { + can_open: is_file && exists, + can_open_default: exists, + can_reveal: exists, + can_rename: exists, + can_delete: exists, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeftoverFileMenuRow { + Open, + OpenDefault, + Reveal, + Refresh, + NewFile, + NewFolder, + Rename, + CopyRel, + CopyAbs, + Delete, + Separator, +} + +pub fn leftover_file_menu_rows() -> &'static [LeftoverFileMenuRow] { + &[ + LeftoverFileMenuRow::Open, + LeftoverFileMenuRow::OpenDefault, + LeftoverFileMenuRow::Reveal, + LeftoverFileMenuRow::Separator, + LeftoverFileMenuRow::Refresh, + LeftoverFileMenuRow::Separator, + LeftoverFileMenuRow::NewFile, + LeftoverFileMenuRow::NewFolder, + LeftoverFileMenuRow::Rename, + LeftoverFileMenuRow::Separator, + LeftoverFileMenuRow::CopyRel, + LeftoverFileMenuRow::CopyAbs, + LeftoverFileMenuRow::Separator, + LeftoverFileMenuRow::Delete, + ] +} + pub fn collect_sidebar_nav_items( projects: &[ProjectWithProfiles], groups: &[ProjectGroup], @@ -953,6 +1079,51 @@ mod tests { assert!(!leftover_new_terminal_split(false, true)); } + #[test] + fn leftover_topbar_and_browser_chrome_match_inventory() { + assert_eq!(leftover_browser_name("chrome"), "Google Chrome"); + assert_eq!(leftover_browser_name("google-chrome"), "Google Chrome"); + assert_eq!(leftover_browser_name("firefox"), "Firefox"); + assert_eq!(leftover_browser_name("safari"), "Safari"); + assert_eq!(leftover_browser_name("brave"), "Brave Browser"); + assert_eq!(leftover_normalize_app_id("code"), "vscode"); + assert_eq!(leftover_normalize_app_id("sublime"), "sublime-text"); + assert_eq!( + leftover_configured_app("code", LEFTOVER_EDITOR_APP_IDS, &["cursor", "vscode"]), + Some("vscode") + ); + assert_eq!( + leftover_configured_app("missing", LEFTOVER_EDITOR_APP_IDS, &["zed"]), + Some("zed") + ); + assert_eq!(leftover_configured_app("cursor", LEFTOVER_EDITOR_APP_IDS, &[]), None); + assert_eq!(leftover_topbar_app_tooltip("Editor", "VS Code"), "Editor · VS Code"); + assert!(!leftover_pr_visible(false)); + assert!(leftover_pr_visible(true)); + assert_eq!(leftover_pr_label(12, "Open"), "#12 Open"); + assert_eq!(leftover_launch_app_i18n("code"), "topbarVscode"); + assert_eq!(leftover_about_copyright(2026), "© 2026 AkaraChen"); + } + + #[test] + fn leftover_file_menu_matches_inventory() { + let file = leftover_file_menu_flags(true, true); + assert!(file.can_open && file.can_rename && file.can_delete); + let folder = leftover_file_menu_flags(false, true); + assert!(!folder.can_open); + assert!(folder.can_reveal && folder.can_rename); + let missing = leftover_file_menu_flags(true, false); + assert!(!missing.can_open && !missing.can_delete); + assert!(leftover_file_menu_rows().contains(&LeftoverFileMenuRow::Separator)); + assert_eq!( + leftover_file_menu_rows() + .iter() + .filter(|row| matches!(row, LeftoverFileMenuRow::Separator)) + .count(), + 4 + ); + } + #[test] fn leftover_template_dropdown_matches_inventory() { assert_eq!( diff --git a/src-gpui/src/timefmt.rs b/src-gpui/src/timefmt.rs index 54b7ec9a..d026acf8 100644 --- a/src-gpui/src/timefmt.rs +++ b/src-gpui/src/timefmt.rs @@ -11,6 +11,23 @@ pub fn unix_now_secs() -> i64 { .unwrap_or(0) } +pub fn leftover_utc_year(now_secs: i64) -> i32 { + let mut year = 2026; + for _ in 0..8 { + let start = civil_to_unix(year, 1, 1); + let next = civil_to_unix(year + 1, 1, 1); + if now_secs >= start && now_secs < next { + return year; + } + if now_secs < start { + year -= 1; + } else { + year += 1; + } + } + year +} + pub fn unix_now_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -273,6 +290,12 @@ mod tests { assert_eq!(format_release_date_at("not-a-date", Locale::En, now), "not-a-date"); } + #[test] + fn leftover_utc_year_matches_inventory() { + assert_eq!(leftover_utc_year(civil_to_unix(2026, 9, 3)), 2026); + assert_eq!(leftover_utc_year(civil_to_unix(2027, 1, 1)), 2027); + } + #[test] fn leftover_relative_time_value_buckets() { let now = parse_iso8601_secs("2026-05-15T00:00:00Z").unwrap() * 1000; diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index eeda3279..5294b557 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -8,8 +8,9 @@ use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::backend; use crate::state::{ - leftover_dialog_width, leftover_rename_disabled, leftover_template_rows, project_group_menu_rows, ContextMenu, - DialogKind, GroupMenuRow, LeftoverTemplateRow, + leftover_dialog_width, leftover_file_menu_flags, leftover_file_menu_rows, leftover_rename_disabled, + leftover_template_rows, project_group_menu_rows, ContextMenu, DialogKind, GroupMenuRow, LeftoverFileMenuRow, + LeftoverTemplateRow, }; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -1056,7 +1057,11 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> }); } }) - .child(div().text_sm().child(browser.id)) + .child(div().text_sm().child(if browser.name.is_empty() { + crate::state::leftover_browser_name(browser.id) + } else { + browser.name + })) })), ) }) @@ -1102,6 +1107,15 @@ fn context_menu(app: &AppView, cx: &mut Context) -> impl IntoElement { .shadow_md() .on_click(|_, _, _| {}) .children(items.into_iter().map(|item| { + if item.header && item.label.is_empty() { + return div() + .id(crate::ui::eid(item.id.clone())) + .h(px(1.)) + .mx_1() + .my_1() + .bg(theme.border) + .into_any_element(); + } if item.input { return div() .id(crate::ui::eid(item.id.clone())) @@ -1222,6 +1236,72 @@ fn header(id: impl Into, label: impl Into) -> MenuItem { item } +fn leftover_sep(id: impl Into) -> MenuItem { + header(id, "") +} + +fn leftover_disabled(mut item: MenuItem, disabled: bool) -> MenuItem { + item.disabled = disabled; + item +} + +fn leftover_file_context_items(app: &AppView, path: &str) -> Vec { + let node = app.data.current_ws().and_then(|ws| ws.tree.get(path)); + let flags = leftover_file_menu_flags(node.is_some_and(|n| !n.is_dir), node.is_some()); + leftover_file_menu_rows() + .iter() + .enumerate() + .map(|(ix, row)| match row { + LeftoverFileMenuRow::Open => leftover_disabled( + item("open", app.t("fileTreeContextMenuOpen"), MenuAction::Open), + !flags.can_open, + ), + LeftoverFileMenuRow::OpenDefault => leftover_disabled( + item( + "open-default", + app.t("fileTreeContextMenuOpenInDefaultApp"), + MenuAction::OpenDefault, + ), + !flags.can_open_default, + ), + LeftoverFileMenuRow::Reveal => leftover_disabled( + item( + "reveal", + app.t("fileTreeContextMenuRevealInFileManager"), + MenuAction::Reveal, + ), + !flags.can_reveal, + ), + LeftoverFileMenuRow::Refresh => item("refresh", app.t("fileTreeContextMenuRefresh"), MenuAction::Refresh), + LeftoverFileMenuRow::NewFile => item("new-file", app.t("fileTreeContextMenuNewFile"), MenuAction::NewFile), + LeftoverFileMenuRow::NewFolder => item( + "new-folder", + app.t("fileTreeContextMenuNewFolder"), + MenuAction::NewFolder, + ), + LeftoverFileMenuRow::Rename => leftover_disabled( + item("rename-path", app.t("rename"), MenuAction::RenamePath), + !flags.can_rename, + ), + LeftoverFileMenuRow::CopyRel => item( + "copy-rel", + app.t("fileTreeContextMenuCopyRelativePath"), + MenuAction::CopyRel, + ), + LeftoverFileMenuRow::CopyAbs => item( + "copy-abs", + app.t("fileTreeContextMenuCopyAbsolutePath"), + MenuAction::CopyAbs, + ), + LeftoverFileMenuRow::Delete => leftover_disabled( + danger(item("del-path", app.t("delete"), MenuAction::DeletePath)), + !flags.can_delete, + ), + LeftoverFileMenuRow::Separator => leftover_sep(format!("file-sep-{ix}")), + }) + .collect() +} + fn project_menu_items(app: &AppView, project_id: &str) -> Vec { let current = app .data @@ -1302,38 +1382,7 @@ fn menu_items(app: &AppView, menu: &ContextMenu) -> Vec { app.t("gitDiscardFileAction"), MenuAction::DiscardGitFile, ))], - ContextMenu::File { .. } => vec![ - item("open", app.t("fileTreeContextMenuOpen"), MenuAction::Open), - item( - "open-default", - app.t("fileTreeContextMenuOpenInDefaultApp"), - MenuAction::OpenDefault, - ), - item( - "reveal", - app.t("fileTreeContextMenuRevealInFileManager"), - MenuAction::Reveal, - ), - item("refresh", app.t("fileTreeContextMenuRefresh"), MenuAction::Refresh), - item("new-file", app.t("fileTreeContextMenuNewFile"), MenuAction::NewFile), - item( - "new-folder", - app.t("fileTreeContextMenuNewFolder"), - MenuAction::NewFolder, - ), - item("rename-path", app.t("rename"), MenuAction::RenamePath), - item( - "copy-rel", - app.t("fileTreeContextMenuCopyRelativePath"), - MenuAction::CopyRel, - ), - item( - "copy-abs", - app.t("fileTreeContextMenuCopyAbsolutePath"), - MenuAction::CopyAbs, - ), - danger(item("del-path", app.t("delete"), MenuAction::DeletePath)), - ], + ContextMenu::File { path } => leftover_file_context_items(app, path), ContextMenu::TreeBlank => vec![ item( "blank-new-file", diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index 808f27e3..548ade18 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -1,9 +1,8 @@ use std::time::Duration; use gpui::{div, prelude::*, px, Animation, AnimationExt, Context, MouseButton, Window}; -use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::Input; -use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt}; +use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, StyledExt}; use crate::app::AppView; use crate::state::ContextMenu; @@ -207,40 +206,10 @@ fn node_view( }) .child(crate::ui::file_icons::file_glyph(path, node.is_dir, node.expanded, 13.)) .child(if renaming { - h_flex() + div() .flex_1() - .gap_1() - .child(div().flex_1().child(Input::new(&app.inputs.rename))) - .child( - Button::new(crate::ui::eid(format!("rename-ok-{path}"))) - .xsmall() - .primary() - .icon(IconName::Check) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.commit_rename_path(cx); - cx.notify(); - }); - } - }), - ) - .child( - Button::new(crate::ui::eid(format!("rename-cancel-{path}"))) - .xsmall() - .ghost() - .icon(IconName::Close) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.data.overlay.renaming_path = None; - cx.notify(); - }); - } - }), - ) + .min_w_0() + .child(Input::new(&app.inputs.rename)) .into_any_element() } else { div() diff --git a/src-gpui/src/ui/git.rs b/src-gpui/src/ui/git.rs index 7122b192..91122f5a 100644 --- a/src-gpui/src/ui/git.rs +++ b/src-gpui/src/ui/git.rs @@ -44,10 +44,14 @@ pub fn render_panel(app: &mut AppView, window: &mut Window, cx: &mut Context) -> .size(px(48.)) .rounded_full() .bg(theme.muted) - .relative() .flex() .items_center() .justify_center() - .child(Icon::new(IconName::Folder).w(px(22.))) - .child( - div() - .absolute() - .bottom(px(4.)) - .right(px(4.)) - .size(px(16.)) - .rounded_full() - .bg(theme.background) - .flex() - .items_center() - .justify_center() - .child(Icon::new(IconName::Plus).w(px(10.))), - ), + .child(crate::ui::leftover_folder_plus_mark(22., 10.)), ) .child(div().font_semibold().child(app.t("emptyProjectsTitle"))) .child( diff --git a/src-gpui/src/ui/notes.rs b/src-gpui/src/ui/notes.rs index 05113b59..f00120f4 100644 --- a/src-gpui/src/ui/notes.rs +++ b/src-gpui/src/ui/notes.rs @@ -1,25 +1,14 @@ use gpui::{div, prelude::*, Context, Window}; use gpui_component::input::Input; -use gpui_component::{h_flex, v_flex, ActiveTheme, StyledExt}; +use gpui_component::v_flex; use crate::app::AppView; use crate::ui::markdown::{self, MarkupTarget}; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme().clone(); v_flex() .id("notes-panel") .size_full() - .child( - h_flex() - .w_full() - .px_2() - .py_1() - .justify_between() - .border_b_1() - .border_color(theme.border) - .child(div().text_xs().font_semibold().child(app.t("notes"))), - ) .child(markdown::toolbar(app, MarkupTarget::Notes, window, cx)) .child( div() diff --git a/src-gpui/src/ui/palette.rs b/src-gpui/src/ui/palette.rs index 1c64a240..e70c40ea 100644 --- a/src-gpui/src/ui/palette.rs +++ b/src-gpui/src/ui/palette.rs @@ -20,6 +20,7 @@ pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context let q = app.inputs.palette.read(cx).value().to_string(); let results = app.data.overlay.palette_results.clone(); let selected = app.data.overlay.palette_index; + let error = app.data.overlay.palette_error.clone(); div() .id("palette-mask") @@ -46,7 +47,7 @@ pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context .w(px(640.)) .max_h(px(520.)) .rounded_lg() - .bg(theme.background) + .bg(theme.popover) .border_1() .border_color(theme.border) .shadow_lg() @@ -74,7 +75,15 @@ pub fn render(app: &mut AppView, _window: &mut Window, cx: &mut Context .border_color(theme.border) .child(Input::new(&app.inputs.palette)), ) - .child(if results.is_empty() { + .child(if let Some(error) = error.filter(|_| results.is_empty()) { + div() + .flex() + .justify_center() + .px_4() + .py_8() + .child(div().text_sm().text_color(theme.danger).child(error)) + .into_any_element() + } else if results.is_empty() { div() .flex() .justify_center() diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 6b351517..d8a29c4f 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -857,11 +857,29 @@ impl SettingsView { }, )) .child(field_label(&self.t("notificationSound"))) - .child(div().text_sm().child(if self.prefs.notification_sound.is_empty() { - self.t("notificationSoundNone") - } else { - self.prefs.notification_sound.clone() - })) + .child( + h_flex() + .gap_2() + .items_center() + .child(div().text_sm().child(if self.prefs.notification_sound.is_empty() { + self.t("notificationSoundNone") + } else { + self.prefs.notification_sound.clone() + })) + .child( + Button::new("preview-sound") + .ghost() + .small() + .icon(IconName::Bell) + .disabled(self.prefs.notification_sound.is_empty()) + .on_click({ + let sound = self.prefs.notification_sound.clone(); + move |_, _, _| { + let _ = crate::platform::play_system_sound(&sound); + } + }), + ), + ) .child(if self.sounds.is_empty() { v_flex() .gap_1() @@ -1310,6 +1328,14 @@ impl SettingsView { } }, )) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(crate::state::leftover_about_copyright( + crate::timefmt::leftover_utc_year(crate::timefmt::unix_now_secs()), + )), + ) } } diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index ae78a8ac..f6ab9ff6 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -6,8 +6,9 @@ use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::state::{ - leftover_new_terminal_split, leftover_template_rows, AgentKind, DialogKind, LeftoverTemplateRow, SidebarMode, - UnifiedTab, + leftover_configured_app, leftover_launch_app_i18n, leftover_new_terminal_split, leftover_pr_label, + leftover_pr_visible, leftover_template_rows, leftover_topbar_app_tooltip, AgentKind, DialogKind, + LeftoverTemplateRow, SidebarMode, UnifiedTab, LEFTOVER_EDITOR_APP_IDS, LEFTOVER_TERMINAL_APP_IDS, }; use crate::ui::{file_tree, file_viewer, git, leftover_branch_glyph, notes, sidebar, terminal, tip}; @@ -324,61 +325,73 @@ fn topbar_control( } }) .into_any_element(), - "editor" => Button::new("tb-ed") - .small() - .icon(IconName::ALargeSmall) - .tooltip(app.t("topbarEditor")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, _| app.open_topbar_app("editor")); - } - }) - .into_any_element(), - "terminal" => Button::new("tb-term") - .small() - .icon(IconName::SquareTerminal) - .tooltip(app.t("topbarTerminal")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, _| app.open_topbar_app("terminal")); - } - }) - .into_any_element(), + "editor" => { + let installed: Vec<_> = crate::platform::installed_editors(); + let ids: Vec<&str> = installed.iter().map(|app| app.id).collect(); + let Some(app_id) = leftover_configured_app(&app.data.prefs.editor_app, LEFTOVER_EDITOR_APP_IDS, &ids) + else { + return div().into_any_element(); + }; + let tip = leftover_topbar_app_tooltip(&app.t("topbarEditor"), &app.t(leftover_launch_app_i18n(app_id))); + Button::new("tb-ed") + .small() + .icon(IconName::ALargeSmall) + .tooltip(tip) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_topbar_app("editor")); + } + }) + .into_any_element() + } + "terminal" => { + let installed: Vec<_> = crate::platform::installed_terminals(); + let ids: Vec<&str> = installed.iter().map(|app| app.id).collect(); + let Some(app_id) = leftover_configured_app(&app.data.prefs.terminal_app, LEFTOVER_TERMINAL_APP_IDS, &ids) + else { + return div().into_any_element(); + }; + let tip = leftover_topbar_app_tooltip(&app.t("topbarTerminal"), &app.t(leftover_launch_app_i18n(app_id))); + Button::new("tb-term") + .small() + .icon(IconName::SquareTerminal) + .tooltip(tip) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, _| app.open_topbar_app("terminal")); + } + }) + .into_any_element() + } "pr-status" => { - let (label, tip) = match pr { - None if pr_error.is_some() => (app.t("topbarPrNoPr"), app.t("topbarPrCheckFailedDescription")), - None => (app.t("topbarPrNoPr"), app.t("topbarPrNoPrTooltip")), - Some(p) if p.is_draft => ( - app.t("topbarPrDraft"), - crate::i18n::tf( - app.data.locale, - "topbarPrTooltip", - &[ - ("number", &p.number.to_string()), - ("title", &p.title), - ("state", "draft"), - ], - ), - ), - Some(p) => ( - match p.state.as_str() { - "MERGED" => app.t("topbarPrMerged"), - "CLOSED" => app.t("topbarPrClosed"), - _ => app.t("topbarPrOpen"), - }, - crate::i18n::tf( - app.data.locale, - "topbarPrTooltip", - &[ - ("number", &p.number.to_string()), - ("title", &p.title), - ("state", &p.state), - ], - ), - ), + if !leftover_pr_visible(pr.is_some()) { + let _ = pr_error; + return div().into_any_element(); + } + let Some(p) = pr else { + return div().into_any_element(); }; + let state = if p.is_draft { + app.t("topbarPrDraft") + } else { + match p.state.as_str() { + "MERGED" => app.t("topbarPrMerged"), + "CLOSED" => app.t("topbarPrClosed"), + _ => app.t("topbarPrOpen"), + } + }; + let label = leftover_pr_label(p.number, &state); + let tip = crate::i18n::tf( + app.data.locale, + "topbarPrTooltip", + &[ + ("number", &p.number.to_string()), + ("title", &p.title), + ("state", &state), + ], + ); Button::new("tb-pr") .small() .icon(IconName::GitHub) From 41fabe1d399b346596dc69a7de1ad3ca7d37005f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 09:25:30 +0000 Subject: [PATCH 45/50] Match leftover About update card and section order. Use leftover max-w-2xl layout, version badge beside the title, outline repo/releases, and a bordered Update card with leftover available/not-available badges. Co-authored-by: AkaraChen --- src-gpui/src/state.rs | 25 +++ src-gpui/src/ui/settings.rs | 431 ++++++++++++++++++++++-------------- 2 files changed, 286 insertions(+), 170 deletions(-) diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index aef1f491..ecae6960 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -899,6 +899,27 @@ pub fn leftover_about_copyright(year: i32) -> String { format!("© {year} AkaraChen") } +pub fn leftover_about_max_width() -> f32 { + 672.0 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LeftoverUpdateBadge { + Hidden, + Available, + NotAvailable, +} + +pub fn leftover_update_badge(has_update: bool, checked_not_available: bool) -> LeftoverUpdateBadge { + if has_update { + LeftoverUpdateBadge::Available + } else if checked_not_available { + LeftoverUpdateBadge::NotAvailable + } else { + LeftoverUpdateBadge::Hidden + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LeftoverFileMenuFlags { pub can_open: bool, @@ -1103,6 +1124,10 @@ mod tests { assert_eq!(leftover_pr_label(12, "Open"), "#12 Open"); assert_eq!(leftover_launch_app_i18n("code"), "topbarVscode"); assert_eq!(leftover_about_copyright(2026), "© 2026 AkaraChen"); + assert_eq!(leftover_about_max_width(), 672.0); + assert_eq!(leftover_update_badge(true, false), LeftoverUpdateBadge::Available); + assert_eq!(leftover_update_badge(false, true), LeftoverUpdateBadge::NotAvailable); + assert_eq!(leftover_update_badge(false, false), LeftoverUpdateBadge::Hidden); } #[test] diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index d8a29c4f..34918c2c 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -1120,9 +1120,14 @@ impl SettingsView { fn about(&mut self, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); + let theme = cx.theme().clone(); + let badge = crate::state::leftover_update_badge( + self.latest_version.is_some(), + self.update_status == self.t("updateNotAvailableDescription"), + ); v_flex() - .gap_3() - .max_w(px(480.)) + .gap_8() + .max_w(px(crate::state::leftover_about_max_width())) .child( h_flex() .gap_5() @@ -1138,200 +1143,292 @@ impl SettingsView { ) .child( v_flex() - .gap_1() + .gap(px(6.)) .min_w_0() - .child(div().text_xl().font_semibold().child("2Code")) + .child( + h_flex() + .gap(px(10.)) + .items_center() + .child(div().text_xl().font_semibold().child("2code")) + .child( + Button::new("ver") + .xsmall() + .label(crate::i18n::tf( + self.locale, + "currentVersion", + &[("version", env!("CARGO_PKG_VERSION"))], + )) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + cx.write_to_clipboard(gpui::ClipboardItem::new_string( + env!("CARGO_PKG_VERSION").into(), + )); + view.update(cx, |this, cx| { + if let Some(main) = this.main.upgrade() { + main.update(cx, |app, cx| { + app.data.push_toast( + crate::state::ToastKind::Success, + app.t("aboutVersionCopied"), + "", + ); + cx.notify(); + }); + } + }); + } + }), + ), + ) .child( div() .text_sm() - .text_color(cx.theme().muted_foreground) + .text_color(theme.muted_foreground) .child(self.t("aboutAppDescription")), ), ), ) - .child( - Button::new("ver") - .label(crate::i18n::tf( - self.locale, - "currentVersion", - &[("version", env!("CARGO_PKG_VERSION"))], - )) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - cx.write_to_clipboard(gpui::ClipboardItem::new_string( - env!("CARGO_PKG_VERSION").into(), - )); - view.update(cx, |this, cx| { - if let Some(main) = this.main.upgrade() { - main.update(cx, |app, cx| { - app.data.push_toast( - crate::state::ToastKind::Success, - app.t("aboutVersionCopied"), - "", - ); - cx.notify(); - }); - } - }); - } - }), - ) - .child(div().font_semibold().child(self.t("contributors"))) .child( h_flex() .gap_2() - .items_center() + .flex_wrap() .child( - img("https://github.com/AkaraChen.png?size=96") - .id("about-maintainer-avatar") - .size(px(32.)) - .rounded_full(), + Button::new("repo") + .outline() + .small() + .label(self.t("repository")) + .on_click(|_, _, _| { + let _ = open::that("https://github.com/AkaraChen/2code"); + }), ) .child( - v_flex() - .child(div().text_sm().child("AkaraChen")) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(self.t("primaryContributorDescription")), - ), + Button::new("rel") + .outline() + .small() + .label(self.t("releases")) + .on_click(|_, _, _| { + let _ = open::that("https://github.com/AkaraChen/2code/releases"); + }), ), ) .child( - h_flex() - .gap_2() - .child(link_btn("repo", self.t("repository"), "https://github.com/AkaraChen/2code")) - .child(link_btn("rel", self.t("releases"), "https://github.com/AkaraChen/2code/releases")) - .child(link_btn("web", self.t("website"), "https://github.com/AkaraChen/2code")), - ) - .child(div().font_semibold().child(self.t("update"))) - .child(div().text_sm().child(self.update_status.clone())) - .child( - if let Some(date) = &self.released_at { - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(crate::i18n::tf( - self.locale, - "updateReleasedAt", - &[("date", &crate::updater::format_release_date_display(date, self.locale))], - )) - .into_any_element() - } else { - div().into_any_element() - }, - ) - .child( - h_flex() - .gap_2() + v_flex() + .id("about-update-card") + .w_full() + .overflow_hidden() + .rounded_xl() + .border_1() + .border_color(theme.border) .child( - Button::new("check-upd") - .label(self.t("checkForUpdates")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |this, cx| { - this.update_status = this.t("checkForUpdates"); - let result = - crate::updater::check_for_update(this.prefs.accept_beta); - match &result { - Ok(info) if info.available => { - this.latest_version = Some(info.latest_version.clone()); - this.released_at = info.released_at.clone(); - this.latest_url = info.html_url.clone(); - this.update_status = crate::i18n::tf( - this.locale, + h_flex() + .min_h(px(44.)) + .w_full() + .px_4() + .py_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(theme.border) + .bg(theme.muted.opacity(0.4)) + .child(div().text_sm().font_medium().child(self.t("update"))) + .when(badge != crate::state::LeftoverUpdateBadge::Hidden, |el| { + el.child( + div() + .px_2() + .py(px(2.)) + .rounded_full() + .when(badge == crate::state::LeftoverUpdateBadge::Available, |el| { + el.bg(theme.primary).text_color(theme.primary_foreground) + }) + .when(badge == crate::state::LeftoverUpdateBadge::NotAvailable, |el| { + el.border_1().border_color(theme.border).text_color(theme.muted_foreground) + }) + .child(div().text_xs().child(if badge == crate::state::LeftoverUpdateBadge::Available { + if let Some(ver) = &self.latest_version { + crate::i18n::tf( + self.locale, "updateAvailableTitle", - &[("version", &info.latest_version)], - ); + &[("version", ver)], + ) + } else { + self.t("updateAvailableTitle") } - Ok(_) => { - this.latest_version = None; - this.released_at = None; - this.update_status = - this.t("updateNotAvailableDescription"); - } - Err(err) => { - this.update_status = format!( - "{}: {err}", - this.t("updateCheckFailedTitle") - ); - } - } - if let Some(main) = this.main.upgrade() { - main.update(cx, |app, cx| { - app.apply_update_result(result, false); - cx.notify(); - }); - } - cx.notify(); - }); - } + } else { + self.t("updateNotAvailableTitle") + })), + ) }), ) .child( - Button::new("open-upd") - .label(if let Some(ver) = &self.latest_version { - crate::i18n::tf( - self.locale, - "installUpdate", - &[("version", ver)], - ) - } else { - self.t("openUpdatePage") - }) - .on_click({ - let view = view.clone(); - let url = self.latest_url.clone(); - let has_update = self.latest_version.is_some(); - move |_, _, cx| { - if has_update { + v_flex() + .gap_4() + .p_4() + .child(switch_row( + "beta", + self.t("acceptBetaUpdates"), + self.t("acceptBetaUpdatesDescription"), + self.prefs.accept_beta, + { + let view = view.clone(); + move |val, cx| { view.update(cx, |this, cx| { - this.update_status = this.t("checkForUpdates"); - match crate::updater::download_and_install(this.prefs.accept_beta) { - Ok(path) => { - this.update_status = path; - cx.quit(); - } - Err(err) => { - this.update_status = format!( - "{}: {err}", - this.t("updateInstallFailedTitle") - ); - let _ = open::that(&url); - } - } + this.prefs.accept_beta = val; + this.persist(cx); cx.notify(); }); - } else { - let _ = open::that(&url); } - } - }), + }, + )) + .child( + v_flex() + .gap_1() + .child( + div() + .text_sm() + .when(self.latest_version.is_none(), |el| { + el.text_color(theme.muted_foreground) + }) + .child(self.update_status.clone()), + ) + .when_some(self.released_at.clone(), |el, date| { + el.child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(crate::i18n::tf( + self.locale, + "updateReleasedAt", + &[("date", &crate::updater::format_release_date_display(&date, self.locale))], + )), + ) + }), + ) + .child( + h_flex() + .gap_2() + .flex_wrap() + .child( + Button::new("check-upd") + .outline() + .small() + .label(self.t("checkForUpdates")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.update_status = this.t("checkForUpdates"); + let result = + crate::updater::check_for_update(this.prefs.accept_beta); + match &result { + Ok(info) if info.available => { + this.latest_version = Some(info.latest_version.clone()); + this.released_at = info.released_at.clone(); + this.latest_url = info.html_url.clone(); + this.update_status = crate::i18n::tf( + this.locale, + "updateAvailableDescription", + &[ + ("currentVersion", env!("CARGO_PKG_VERSION")), + ("version", &info.latest_version), + ], + ); + } + Ok(_) => { + this.latest_version = None; + this.released_at = None; + this.update_status = + this.t("updateNotAvailableDescription"); + } + Err(err) => { + this.update_status = format!( + "{}: {err}", + this.t("updateCheckFailedTitle") + ); + } + } + if let Some(main) = this.main.upgrade() { + main.update(cx, |app, cx| { + app.apply_update_result(result, false); + cx.notify(); + }); + } + cx.notify(); + }); + } + }), + ) + .when(self.latest_version.is_some(), |el| { + el.child( + Button::new("open-upd") + .small() + .label(if let Some(ver) = &self.latest_version { + crate::i18n::tf( + self.locale, + "installUpdate", + &[("version", ver)], + ) + } else { + self.t("openUpdatePage") + }) + .on_click({ + let view = view.clone(); + let url = self.latest_url.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.update_status = this.t("checkForUpdates"); + match crate::updater::download_and_install(this.prefs.accept_beta) { + Ok(path) => { + this.update_status = path; + cx.quit(); + } + Err(err) => { + this.update_status = format!( + "{}: {err}", + this.t("updateInstallFailedTitle") + ); + let _ = open::that(&url); + } + } + cx.notify(); + }); + } + }), + ) + }), + ), + ), + ) + .child(div().text_sm().font_medium().child(self.t("contributors"))) + .child( + h_flex() + .gap_3() + .items_center() + .px_3() + .py_2() + .rounded_lg() + .border_1() + .border_color(theme.border) + .child( + img("https://github.com/AkaraChen.png?size=96") + .id("about-maintainer-avatar") + .size(px(36.)) + .rounded_full(), + ) + .child( + v_flex() + .child(div().text_sm().font_medium().child("AkaraChen")) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(self.t("primaryContributorDescription")), + ), ), ) - .child(switch_row( - "beta", - self.t("acceptBetaUpdates"), - self.t("acceptBetaUpdatesDescription"), - self.prefs.accept_beta, - { - let view = view.clone(); - move |val, cx| { - view.update(cx, |this, cx| { - this.prefs.accept_beta = val; - this.persist(cx); - cx.notify(); - }); - } - }, - )) .child( div() .text_xs() - .text_color(cx.theme().muted_foreground) + .text_color(theme.muted_foreground) .child(crate::state::leftover_about_copyright( crate::timefmt::leftover_utc_year(crate::timefmt::unix_now_secs()), )), @@ -1418,12 +1515,6 @@ fn switch_row( .child(Switch::new(id).checked(checked).on_click(move |val, _, cx| on(*val, cx))) } -fn link_btn(id: &'static str, label: String, url: &'static str) -> impl IntoElement { - Button::new(id).small().label(label).on_click(move |_, _, _| { - let _ = open::that(url); - }) -} - fn preview_line(prompt_color: u32, prompt: &str, fg: u32, cmd: &str) -> impl IntoElement { h_flex() .gap_1() From e1d25cedd9b1fca1fe60dbfa5746ec1fe50c099b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 09:29:14 +0000 Subject: [PATCH 46/50] Match leftover NativeSelect settings pickers. Replace chip grids for language, theme, terminal theme, shell, font, sound, and topbar apps with leftover 32px bordered dropdowns; keep leftover ToggleGroup chips for radius. Co-authored-by: AkaraChen --- src-gpui/src/state.rs | 31 ++ src-gpui/src/ui/settings.rs | 628 +++++++++++++++++++++--------------- 2 files changed, 393 insertions(+), 266 deletions(-) diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index ecae6960..2f04f592 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -910,6 +910,24 @@ pub enum LeftoverUpdateBadge { NotAvailable, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LeftoverSelectOption { + pub value: String, + pub label: String, +} + +pub fn leftover_select_label<'a>(options: &'a [LeftoverSelectOption], value: &'a str) -> &'a str { + options + .iter() + .find(|option| option.value == value) + .map(|option| option.label.as_str()) + .unwrap_or(value) +} + +pub fn leftover_native_select_height() -> f32 { + 32.0 +} + pub fn leftover_update_badge(has_update: bool, checked_not_available: bool) -> LeftoverUpdateBadge { if has_update { LeftoverUpdateBadge::Available @@ -1128,6 +1146,19 @@ mod tests { assert_eq!(leftover_update_badge(true, false), LeftoverUpdateBadge::Available); assert_eq!(leftover_update_badge(false, true), LeftoverUpdateBadge::NotAvailable); assert_eq!(leftover_update_badge(false, false), LeftoverUpdateBadge::Hidden); + assert_eq!(leftover_native_select_height(), 32.0); + let options = [ + LeftoverSelectOption { + value: "en".into(), + label: "English".into(), + }, + LeftoverSelectOption { + value: "zh".into(), + label: "中文".into(), + }, + ]; + assert_eq!(leftover_select_label(&options, "zh"), "中文"); + assert_eq!(leftover_select_label(&options, "missing"), "missing"); } #[test] diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index 34918c2c..f9f121d9 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -8,8 +8,9 @@ use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{Disableable, Selectable}; use gpui_component::input::Input; use gpui_component::switch::Switch; +use gpui_component::checkbox::Checkbox; use gpui_component::{ - h_flex, v_flex, ActiveTheme, IconName, Root, Sizable, StyledExt, ThemeMode, + h_flex, v_flex, ActiveTheme, Icon, IconName, Root, Sizable, StyledExt, ThemeMode, }; use gpui_component::tab::Tab; @@ -106,6 +107,7 @@ pub struct SettingsView { sounds: Vec, editing_template_id: Option, preview_theme: Option, + open_select: Option, } impl SettingsView { @@ -163,6 +165,7 @@ impl SettingsView { sounds: crate::platform::list_system_sounds(), editing_template_id: None, preview_theme: None, + open_select: None, } } @@ -231,6 +234,7 @@ impl gpui::Render for SettingsView { .flex_1() .min_h_0() .p_5() + .overflow_y_scroll() .child(match self.tab { SettingsTab::General => self.general(window, cx).into_any_element(), SettingsTab::Terminal => self.terminal(window, cx).into_any_element(), @@ -251,62 +255,57 @@ impl SettingsView { .max_w(px(448.)) .gap_6() .child(field_label(&self.t("language"))) - .child( - h_flex() - .gap_2() - .child(choice("lang-en", "English", self.locale == Locale::En, { - let view = view.clone(); - move |cx| { - view.update(cx, |this, cx| { - this.locale = Locale::En; - this.persist(cx); - cx.notify(); - }); - } - })) - .child(choice("lang-zh", "中文", self.locale == Locale::Zh, { - let view = view.clone(); - move |cx| { - view.update(cx, |this, cx| { - this.locale = Locale::Zh; - this.persist(cx); - cx.notify(); - }); - } - })), - ) + .child(leftover_select( + "language", + match self.locale { + Locale::En => "English", + Locale::Zh => "中文", + }, + self.open_select.as_deref() == Some("language"), + false, + vec![ + ("en".into(), "English".into()), + ("zh".into(), "中文".into()), + ], + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.locale = if value == "zh" { Locale::Zh } else { Locale::En }; + this.persist(cx); + }, + )) .child(field_label(&self.t("theme"))) - .child( - h_flex() - .gap_2() - .children( - [ - (ThemePref::System, self.t("themeSystem")), - (ThemePref::Light, self.t("themeLight")), - (ThemePref::Dark, self.t("themeDark")), - ] - .into_iter() - .map(|(pref, label)| { - let selected = self.prefs.theme == pref; - choice(format!("theme-{label}"), &label, selected, { - let view = view.clone(); - move |cx| { - view.update(cx, |this, cx| { - this.prefs.theme = pref; - let mode = match pref { - ThemePref::Dark => ThemeMode::Dark, - ThemePref::Light => ThemeMode::Light, - ThemePref::System => cx.window_appearance().into(), - }; - gpui_component::Theme::change(mode, None, cx); - this.persist(cx); - cx.notify(); - }); - } - }) - }), - ), - ) + .child(leftover_select( + "theme", + &match self.prefs.theme { + ThemePref::System => self.t("themeSystem"), + ThemePref::Light => self.t("themeLight"), + ThemePref::Dark => self.t("themeDark"), + }, + self.open_select.as_deref() == Some("theme"), + false, + vec![ + ("system".into(), self.t("themeSystem")), + ("light".into(), self.t("themeLight")), + ("dark".into(), self.t("themeDark")), + ], + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.theme = match value.as_str() { + "light" => ThemePref::Light, + "dark" => ThemePref::Dark, + _ => ThemePref::System, + }; + let mode = match this.prefs.theme { + ThemePref::Dark => ThemeMode::Dark, + ThemePref::Light => ThemeMode::Light, + ThemePref::System => cx.window_appearance().into(), + }; + gpui_component::Theme::change(mode, None, cx); + this.persist(cx); + }, + )) .child(field_label(&self.t("borderRadius"))) .child( h_flex().gap_2().children(RadiusPref::all().into_iter().map(|r| { @@ -470,39 +469,21 @@ impl SettingsView { }); } })) - .child( - v_flex().gap_1().children(TERM_THEMES.iter().map(|t| { - let selected = self.prefs.terminal_theme_dark == t.name; - choice(t.name, t.name, selected, { - let view = view.clone(); - let name = t.name.to_string(); - move |cx| { - view.update(cx, |this, cx| { - this.prefs.terminal_theme_dark = name.clone(); - if this.prefs.sync_terminal_theme { - this.prefs.terminal_theme_light = name.clone(); - } - this.persist(cx); - cx.notify(); - }); - } - }) - })), - ) - .child(switch_row( - "sync-term", - self.t("syncTerminalTheme"), - String::new(), - self.prefs.sync_terminal_theme, - { - let view = view.clone(); - move |val, cx| { - view.update(cx, |this, cx| { - this.prefs.sync_terminal_theme = val; - this.persist(cx); - cx.notify(); - }); + .child(leftover_select( + "term-theme-dark", + &self.prefs.terminal_theme_dark, + self.open_select.as_deref() == Some("term-theme-dark"), + false, + TERM_THEMES.iter().map(|t| (t.name.to_string(), t.name.to_string())).collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.terminal_theme_dark = value.clone(); + if this.prefs.sync_terminal_theme { + this.prefs.terminal_theme_light = value; } + this.preview_theme = None; + this.persist(cx); }, )) .when(!self.prefs.sync_terminal_theme, |el| { @@ -516,78 +497,120 @@ impl SettingsView { }); } })) - .child( - v_flex().gap_1().children(TERM_THEMES.iter().map(|t| { - let selected = self.prefs.terminal_theme_light == t.name; - choice(format!("light-{name}", name = t.name), t.name, selected, { - let view = view.clone(); - let name = t.name.to_string(); - move |cx| { - view.update(cx, |this, cx| { - this.prefs.terminal_theme_light = name.clone(); - this.persist(cx); - cx.notify(); - }); - } - }) - })), - ) + .child(leftover_select( + "term-theme-light", + &self.prefs.terminal_theme_light, + self.open_select.as_deref() == Some("term-theme-light"), + false, + TERM_THEMES.iter().map(|t| (t.name.to_string(), t.name.to_string())).collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.terminal_theme_light = value; + this.preview_theme = None; + this.persist(cx); + }, + )) }) + .child(leftover_check_row( + "sync-term", + self.t("syncTerminalTheme"), + self.prefs.sync_terminal_theme, + { + let view = view.clone(); + move |val, cx| { + view.update(cx, |this, cx| { + this.prefs.sync_terminal_theme = val; + this.persist(cx); + cx.notify(); + }); + } + }, + )) .child(field_label(&self.t("defaultShell"))) + .child(leftover_select( + "shell", + &if custom_selected { + self.t("customShell") + } else if self.prefs.custom_shell == default_shell { + format!("{} ({})", self.prefs.custom_shell, self.t("defaultOption")) + } else { + self.prefs.custom_shell.clone() + }, + self.open_select.as_deref() == Some("shell"), + false, + { + let mut options: Vec<(String, String)> = shells + .into_iter() + .map(|shell| { + let label = if shell == default_shell { + format!("{shell} ({})", self.t("defaultOption")) + } else { + shell.clone() + }; + (shell, label) + }) + .collect(); + options.push(("__custom__".into(), self.t("customShell"))); + options + }, + view.clone(), + cx.theme().clone(), + |this, value, cx| { + if value == "__custom__" { + if crate::platform::list_shells().iter().any(|s| s == &this.prefs.custom_shell) { + this.prefs.custom_shell.clear(); + } + } else { + this.prefs.custom_shell = value; + } + this.persist(cx); + }, + )) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .child(self.t("defaultShellDescription")), ) - .child({ - let mut chips: Vec = shells - .into_iter() - .map(|shell| { - let selected = self.prefs.custom_shell == shell; - let label = if shell == default_shell { - format!("{shell} ({})", self.t("defaultOption")) - } else { - shell.clone() - }; - choice(format!("shell-{shell}"), &label, selected, { - let view = view.clone(); - let shell = shell.clone(); - move |cx| { - view.update(cx, |this, cx| { - this.prefs.custom_shell = shell.clone(); - this.persist(cx); - cx.notify(); - }); - } - }) - .into_any_element() - }) - .collect(); - chips.push( - choice("shell-custom", &self.t("customShell"), custom_selected, { - let view = view.clone(); - move |cx| { - view.update(cx, |this, cx| { - if crate::platform::list_shells().iter().any(|s| s == &this.prefs.custom_shell) { - this.prefs.custom_shell.clear(); - } - this.persist(cx); - cx.notify(); - }); - } - }) - .into_any_element(), - ); - h_flex().gap_1().flex_wrap().children(chips) - }) .when(custom_selected, |el| el.child(Input::new(&self.custom_shell))) .child(field_label(&self.t("terminalFont"))) - .child(div().text_sm().child(self.prefs.font_family.clone())) - .child(switch_row( + .when(fonts.is_empty(), |el| { + el.child(leftover_select( + "font", + &self.t("fontPickerUnavailable"), + false, + true, + vec![("".into(), self.t("fontPickerUnavailable"))], + view.clone(), + cx.theme().clone(), + |_, _, _| {}, + )) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.t("fontPickerUnavailableDescription")), + ) + }) + .when(!fonts.is_empty(), |el| { + el.child(leftover_select( + "font", + &self.prefs.font_family, + self.open_select.as_deref() == Some("font"), + false, + fonts.into_iter().map(|family| (family.clone(), family)).collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.font_family = value; + this.persist(cx); + }, + )) + }) + .child(leftover_check_row( "show-all-fonts", self.t("showAllFonts"), - String::new(), self.prefs.show_all_fonts, { let view = view.clone(); @@ -600,37 +623,6 @@ impl SettingsView { } }, )) - .when(fonts.is_empty(), |el| { - el.child(div().text_sm().child(self.t("fontPickerUnavailable"))).child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(self.t("fontPickerUnavailableDescription")), - ) - }) - .when(!fonts.is_empty(), |el| { - el.child( - div() - .max_h(px(180.)) - .overflow_y_hidden() - .child( - h_flex().gap_1().flex_wrap().children(fonts.into_iter().map(|family| { - let selected = self.prefs.font_family == family; - choice(format!("font-{family}"), &family, selected, { - let view = view.clone(); - let family = family.clone(); - move |cx| { - view.update(cx, |this, cx| { - this.prefs.font_family = family.clone(); - this.persist(cx); - cx.notify(); - }); - } - }) - })), - ), - ) - }) .child(field_label(&self.t("fontSize"))) .child( h_flex() @@ -856,22 +848,18 @@ impl SettingsView { } }, )) - .child(field_label(&self.t("notificationSound"))) .child( h_flex() - .gap_2() + .w_full() .items_center() - .child(div().text_sm().child(if self.prefs.notification_sound.is_empty() { - self.t("notificationSoundNone") - } else { - self.prefs.notification_sound.clone() - })) + .gap_2() + .child(div().flex_1().child(field_label(&self.t("notificationSound")))) .child( Button::new("preview-sound") .ghost() - .small() + .xsmall() .icon(IconName::Bell) - .disabled(self.prefs.notification_sound.is_empty()) + .disabled(!self.prefs.notifications || self.prefs.notification_sound.is_empty() || self.sounds.is_empty()) .on_click({ let sound = self.prefs.notification_sound.clone(); move |_, _, _| { @@ -883,11 +871,16 @@ impl SettingsView { .child(if self.sounds.is_empty() { v_flex() .gap_1() - .p_3() - .rounded_md() - .border_1() - .border_color(cx.theme().border) - .child(div().text_sm().child(self.t("soundPickerUnavailable"))) + .child(leftover_select( + "sound", + &self.t("soundPickerUnavailable"), + false, + true, + vec![("".into(), self.t("soundPickerUnavailable"))], + view.clone(), + cx.theme().clone(), + |_, _, _| {}, + )) .child( div() .text_xs() @@ -896,30 +889,26 @@ impl SettingsView { ) .into_any_element() } else { - h_flex() - .gap_1() - .flex_wrap() - .children(std::iter::once(String::new()).chain(self.sounds.iter().cloned()).map(|name| { - let selected = self.prefs.notification_sound == name; - let label = if name.is_empty() { - self.t("notificationSoundNone") - } else { - name.clone() - }; - choice(format!("snd-{name}"), &label, selected, { - let view = view.clone(); - let name = name.clone(); - move |cx| { - view.update(cx, |this, cx| { - this.prefs.notification_sound = name.clone(); - let _ = crate::platform::play_system_sound(&name); - this.persist(cx); - cx.notify(); - }); - } - }) - })) - .into_any_element() + leftover_select( + "sound", + if self.prefs.notification_sound.is_empty() { + self.t("notificationSoundNone") + } else { + self.prefs.notification_sound.clone() + }, + self.open_select.as_deref() == Some("sound"), + !self.prefs.notifications, + std::iter::once(("".into(), self.t("notificationSoundNone"))) + .chain(self.sounds.iter().cloned().map(|name| (name.clone(), name))) + .collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.notification_sound = value; + this.persist(cx); + }, + ) + .into_any_element() }) } @@ -1047,60 +1036,59 @@ impl SettingsView { .child(if crate::platform::installed_editors().is_empty() { div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDetectingApps")).into_any_element() } else { - h_flex() - .gap_1() - .flex_wrap() - .children(crate::platform::installed_editors().into_iter().map(|app| { - let selected = self.prefs.editor_app == app.id || self.prefs.editor_app == app.command; - let label = match app.id { - "vscode" => self.t("topbarVscode"), - "cursor" => self.t("topbarCursor"), - "windsurf" => self.t("topbarWindsurf"), - "zed" => self.t("topbarZed"), - _ => self.t("topbarSublimeText"), - }; - choice(format!("ed-{id}", id = app.id), &label, selected, { - let view = view.clone(); - let id = app.id.to_string(); - move |cx| { - view.update(cx, |this, cx| { - this.prefs.editor_app = id.clone(); - this.persist(cx); - cx.notify(); - }); - } - }) - })) - .into_any_element() + let editors = crate::platform::installed_editors(); + let current = crate::state::leftover_configured_app( + &self.prefs.editor_app, + crate::state::LEFTOVER_EDITOR_APP_IDS, + &editors.iter().map(|app| app.id).collect::>(), + ) + .unwrap_or(editors[0].id); + leftover_select( + "editor-app", + &self.t(crate::state::leftover_launch_app_i18n(current)), + self.open_select.as_deref() == Some("editor-app"), + false, + editors + .iter() + .map(|app| (app.id.to_string(), self.t(crate::state::leftover_launch_app_i18n(app.id)))) + .collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.editor_app = value; + this.persist(cx); + }, + ) + .into_any_element() }) .child(field_label(&self.t("topbarTerminalApp"))) .child(if crate::platform::installed_terminals().is_empty() { div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("topbarDetectingApps")).into_any_element() } else { - h_flex() - .gap_1() - .flex_wrap() - .children(crate::platform::installed_terminals().into_iter().map(|app| { - let selected = self.prefs.terminal_app == app.id; - let label = match app.id { - "ghostty" => self.t("topbarGhostty"), - "iterm2" => self.t("topbarIterm2"), - "kitty" => self.t("topbarKitty"), - _ => self.t("topbarWarp"), - }; - choice(format!("termapp-{id}", id = app.id), &label, selected, { - let view = view.clone(); - let id = app.id.to_string(); - move |cx| { - view.update(cx, |this, cx| { - this.prefs.terminal_app = id.clone(); - this.persist(cx); - cx.notify(); - }); - } - }) - })) - .into_any_element() + let terminals = crate::platform::installed_terminals(); + let current = crate::state::leftover_configured_app( + &self.prefs.terminal_app, + crate::state::LEFTOVER_TERMINAL_APP_IDS, + &terminals.iter().map(|app| app.id).collect::>(), + ) + .unwrap_or(terminals[0].id); + leftover_select( + "terminal-app", + &self.t(crate::state::leftover_launch_app_i18n(current)), + self.open_select.as_deref() == Some("terminal-app"), + false, + terminals + .iter() + .map(|app| (app.id.to_string(), self.t(crate::state::leftover_launch_app_i18n(app.id)))) + .collect(), + view.clone(), + cx.theme().clone(), + |this, value, cx| { + this.prefs.terminal_app = value; + this.persist(cx); + }, + ) + .into_any_element() }) .child( Button::new("reset-topbar") @@ -1452,6 +1440,7 @@ fn set_tab( .on_click(move |_, _, cx| { view.update(cx, |this, cx| { this.tab = tab; + this.open_select = None; cx.notify(); }); }) @@ -1481,6 +1470,113 @@ fn theme_label_row( ) } +fn leftover_select( + id: &str, + current: impl Into, + open: bool, + disabled: bool, + options: Vec<(String, String)>, + view: gpui::Entity, + theme: gpui_component::Theme, + on_pick: impl Fn(&mut SettingsView, String, &mut Context) + Clone + 'static, +) -> impl IntoElement { + let current = current.into(); + let selected_label = current.clone(); + let height = crate::state::leftover_native_select_height(); + div() + .id(crate::ui::eid(format!("sel-{id}"))) + .relative() + .w_full() + .child( + h_flex() + .id(crate::ui::eid(format!("sel-btn-{id}"))) + .h(px(height)) + .w_full() + .px_3() + .gap_2() + .items_center() + .rounded_md() + .border_1() + .border_color(theme.border) + .bg(theme.background) + .when(disabled, |el| el.opacity(0.5)) + .on_click({ + let view = view.clone(); + let id = id.to_string(); + move |_, _, cx| { + if disabled { + return; + } + view.update(cx, |this, cx| { + this.open_select = if this.open_select.as_deref() == Some(id.as_str()) { + None + } else { + Some(id.clone()) + }; + cx.notify(); + }); + } + }) + .child(div().flex_1().min_w_0().text_sm().child(current)) + .child(Icon::new(IconName::ChevronDown).w(px(14.))), + ) + .when(open && !disabled, |el| { + el.child( + div() + .id(crate::ui::eid(format!("sel-menu-{id}"))) + .absolute() + .top(px(height + 4.)) + .left_0() + .right_0() + .max_h(px(240.)) + .overflow_y_scroll() + .p_1() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_md() + .children(options.into_iter().map(|(value, label)| { + let selected = label == selected_label; + div() + .id(crate::ui::eid(format!("sel-{id}-{value}"))) + .w_full() + .px_2() + .py(px(6.)) + .rounded_md() + .text_sm() + .when(selected, |row| row.bg(theme.muted)) + .hover(|row| row.bg(theme.muted)) + .on_click({ + let view = view.clone(); + let on_pick = on_pick.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + on_pick(this, value.clone(), cx); + this.open_select = None; + cx.notify(); + }); + } + }) + .child(label) + })), + ) + }) +} + +fn leftover_check_row( + id: &'static str, + label: String, + checked: bool, + on: impl Fn(bool, &mut App) + 'static, +) -> impl IntoElement { + h_flex() + .gap_2() + .items_center() + .child(Checkbox::new(id).checked(checked).on_click(move |val, _, cx| on(*val, cx))) + .child(div().text_sm().child(label)) +} + fn choice( id: impl Into, label: &str, From a0a5c545cea9316610ec5b9bdb32e8cc0a29b334 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 09:42:09 +0000 Subject: [PATCH 47/50] Match leftover Templates list and draft dialog. Settings and project settings now use leftover bordered rows with commandPreview, ghost edit/delete, and a 512px TerminalTemplateDraftDialog instead of an always-visible inline form. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 26 ++- src-gpui/src/state.rs | 72 ++++++- src-gpui/src/ui/debug.rs | 2 +- src-gpui/src/ui/dialogs.rs | 355 ++++++++++++++++++++++++-------- src-gpui/src/ui/mod.rs | 4 + src-gpui/src/ui/settings.rs | 397 +++++++++++++++++++++++++++--------- 6 files changed, 669 insertions(+), 187 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index 73a7d129..f1024e36 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -2051,6 +2051,26 @@ impl AppView { self.upsert_project_template(cx); } + pub fn open_project_template_create(&mut self, window: &mut Window, cx: &mut Context) { + self.data.overlay.editing_template = None; + self.inputs.template_name.update(cx, |s, cx| s.set_value("", window, cx)); + self.inputs.template_cwd.update(cx, |s, cx| s.set_value("", window, cx)); + self.inputs.template_commands.update(cx, |s, cx| s.set_value("", window, cx)); + self.data.overlay.template_dialog_open = true; + } + + pub fn close_project_template_draft(&mut self) { + self.data.overlay.template_dialog_open = false; + self.data.overlay.editing_template = None; + } + + pub fn delete_editing_project_template(&mut self) { + if let Some(id) = self.data.overlay.editing_template.clone() { + self.remove_project_template(&id); + } + self.close_project_template_draft(); + } + pub fn load_project_template(&mut self, id: &str, window: &mut Window, cx: &mut Context) { let Some(template) = self .data @@ -2065,6 +2085,7 @@ impl AppView { return; }; self.data.overlay.editing_template = Some(id.to_string()); + self.data.overlay.template_dialog_open = true; self.inputs.template_name.update(cx, |s, cx| { s.set_value(template.name.clone(), window, cx); }); @@ -2143,7 +2164,10 @@ impl AppView { self.persist_prefs(); } self.data.overlay.editing_template = None; - self.data.overlay.dialog = None; + self.data.overlay.template_dialog_open = false; + if self.data.overlay.dialog == Some(crate::state::DialogKind::EditTemplate) { + self.data.overlay.dialog = None; + } } pub fn remove_project_template(&mut self, id: &str) { diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 2f04f592..782eb364 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -620,6 +620,7 @@ pub struct OverlayState { pub collapsed_projects: HashSet, pub pending_close_file: Option, pub editing_template: Option, + pub template_dialog_open: bool, pub project_settings_tab: usize, pub sidebar_drag: Option<(f32, f32)>, pub profile_sidebar_drag: Option<(f32, f32)>, @@ -775,13 +776,55 @@ pub fn leftover_dialog_width(kind: DialogKind) -> f32 { match kind { DialogKind::ReviewQueue => 896.0, DialogKind::SwitchBranch => 448.0, - DialogKind::ProjectSettings => 512.0, - DialogKind::DebugLog => 512.0, + DialogKind::ProjectSettings | DialogKind::DebugLog | DialogKind::EditTemplate => 512.0, DialogKind::ChooseFile => 576.0, _ => 384.0, } } +pub fn leftover_template_draft_width() -> f32 { + 512.0 +} + +pub fn leftover_text_to_commands(text: &str) -> Vec { + text.lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(|line| line.to_string()) + .collect() +} + +pub fn leftover_command_preview(commands_text: &str) -> String { + let lines = leftover_text_to_commands(commands_text); + match lines.as_slice() { + [] => String::new(), + [one] => one.clone(), + [first, rest @ ..] => format!("{} +{}", first, rest.len()), + } +} + +pub fn leftover_template_draft_can_save(name: &str) -> bool { + !name.trim().is_empty() +} + +pub fn leftover_normalize_global_template(name: &str, commands_text: &str) -> Option<(String, Vec)> { + let name = name.trim(); + let commands = leftover_text_to_commands(commands_text); + if name.is_empty() || commands.is_empty() { + None + } else { + Some((name.to_string(), commands)) + } +} + +pub fn leftover_template_display_name(name: &str, fallback: &str) -> String { + if name.is_empty() { + fallback.to_string() + } else { + name.to_string() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum LeftoverTemplateRow { EmptyTitle, @@ -1100,6 +1143,8 @@ mod tests { assert_eq!(leftover_dialog_width(DialogKind::CreateProject), 384.0); assert_eq!(leftover_dialog_width(DialogKind::CreateProfile), 384.0); assert_eq!(leftover_dialog_width(DialogKind::ProjectSettings), 512.0); + assert_eq!(leftover_dialog_width(DialogKind::EditTemplate), 512.0); + assert_eq!(leftover_template_draft_width(), 512.0); assert_eq!(leftover_dialog_width(DialogKind::SwitchBranch), 448.0); assert_eq!(leftover_dialog_width(DialogKind::DebugLog), 512.0); assert_eq!(leftover_dialog_width(DialogKind::ReviewQueue), 896.0); @@ -1180,6 +1225,29 @@ mod tests { ); } + #[test] + fn leftover_template_preview_matches_inventory() { + assert_eq!(leftover_command_preview(""), ""); + assert_eq!(leftover_command_preview("bun test"), "bun test"); + assert_eq!( + leftover_command_preview("bun install\nbun test\nbun lint"), + "bun install +2" + ); + assert_eq!( + leftover_text_to_commands(" bun test \n\n bun lint\n"), + vec!["bun test".to_string(), "bun lint".to_string()] + ); + assert!(leftover_template_draft_can_save("Dev")); + assert!(!leftover_template_draft_can_save(" ")); + assert_eq!( + leftover_normalize_global_template(" Dev ", "bun test\n"), + Some(("Dev".into(), vec!["bun test".into()])) + ); + assert_eq!(leftover_normalize_global_template("Dev", ""), None); + assert_eq!(leftover_template_display_name("", "Terminal Template"), "Terminal Template"); + assert_eq!(leftover_template_display_name("Dev", "Terminal Template"), "Dev"); + } + #[test] fn leftover_template_dropdown_matches_inventory() { assert_eq!( diff --git a/src-gpui/src/ui/debug.rs b/src-gpui/src/ui/debug.rs index fcc168e0..a643e4e5 100644 --- a/src-gpui/src/ui/debug.rs +++ b/src-gpui/src/ui/debug.rs @@ -40,9 +40,9 @@ pub fn render_fab(app: &AppView, _window: &mut Window, cx: &mut Context .child( Button::new("debug-toggle") .primary() - .icon(IconName::Inspector) .rounded(ButtonRounded::Size(px(16.))) .tooltip(app.t("debugLog")) + .child(crate::ui::leftover_wrench_glyph(cx.theme().primary_foreground)) .on_click(move |_, window, cx| { view.update(cx, |app, cx| { app.open_debug_log(window, cx); diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index 5294b557..fb7e93e3 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -8,9 +8,10 @@ use gpui_component::{Disableable, Selectable}; use crate::app::AppView; use crate::backend; use crate::state::{ - leftover_dialog_width, leftover_file_menu_flags, leftover_file_menu_rows, leftover_rename_disabled, - leftover_template_rows, project_group_menu_rows, ContextMenu, DialogKind, GroupMenuRow, LeftoverFileMenuRow, - LeftoverTemplateRow, + leftover_command_preview, leftover_dialog_width, leftover_file_menu_flags, leftover_file_menu_rows, + leftover_rename_disabled, leftover_template_display_name, leftover_template_draft_can_save, + leftover_template_draft_width, leftover_template_rows, project_group_menu_rows, + ContextMenu, DialogKind, GroupMenuRow, LeftoverFileMenuRow, LeftoverTemplateRow, }; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -20,6 +21,156 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .inset_0() .child(context_menu(app, cx)) .child(dialog(app, window, cx)) + .child(leftover_project_template_draft(app, window, cx)) +} + +fn leftover_project_template_draft( + app: &mut AppView, + _window: &mut Window, + cx: &mut Context, +) -> impl IntoElement { + if !app.data.overlay.template_dialog_open || app.data.overlay.dialog != Some(DialogKind::ProjectSettings) { + return div().id("no-ptpl-draft").into_any_element(); + } + let view = cx.entity(); + let theme = cx.theme().clone(); + let editing = app.data.overlay.editing_template.is_some(); + let can_save = leftover_template_draft_can_save(&app.inputs.template_name.read(cx).value()); + div() + .id("ptpl-draft-mask") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::hsla(0., 0., 0., 0.12)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.close_project_template_draft(); + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("ptpl-draft-panel") + .w(px(leftover_template_draft_width())) + .p_4() + .gap_4() + .rounded_xl() + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .shadow_lg() + .on_click(|_, _, _| {}) + .child( + h_flex() + .gap_2() + .items_center() + .child(Icon::new(IconName::SquareTerminal).w(px(16.))) + .child( + div().font_semibold().child(if editing { + app.t("editTerminalTemplate") + } else { + app.t("addTerminalTemplate") + }), + ), + ) + .child( + v_flex() + .gap_4() + .child( + v_flex() + .gap_1() + .child(div().font_medium().text_sm().child(app.t("terminalTemplateName"))) + .child(Input::new(&app.inputs.template_name)), + ) + .child( + v_flex() + .gap_1() + .child(div().font_medium().text_sm().child(app.t("terminalTemplateCwd"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("terminalTemplateCwdDescription")), + ) + .child(Input::new(&app.inputs.template_cwd)), + ) + .child( + v_flex() + .gap_1() + .child(div().font_medium().text_sm().child(app.t("terminalTemplateCommands"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(app.t("terminalTemplateCommandsDescription")), + ) + .child(Input::new(&app.inputs.template_commands)), + ), + ) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_2() + .child( + Button::new("ptpl-cancel") + .outline() + .small() + .label(app.t("cancel")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.close_project_template_draft(); + cx.notify(); + }); + } + }), + ) + .when(editing, |el| { + el.child( + Button::new("ptpl-delete") + .danger() + .small() + .label(app.t("delete")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.delete_editing_project_template(); + cx.notify(); + }); + } + }), + ) + }), + ) + .child( + Button::new("ptpl-save") + .primary() + .small() + .label(app.t("save")) + .disabled(!can_save) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.add_project_template(cx); + app.data.overlay.template_dialog_open = false; + cx.notify(); + }); + } + }), + ), + ), + ) + .into_any_element() } fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -39,7 +190,13 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> DialogKind::SwitchBranch => app.t("switchBranchTitle"), DialogKind::OpenLink => app.t("terminalOpenLink"), DialogKind::ChooseFile => app.t("terminalChooseFilePath"), - DialogKind::EditTemplate => app.t("editTerminalTemplate"), + DialogKind::EditTemplate => { + if app.data.overlay.editing_template.is_some() { + app.t("editTerminalTemplate") + } else { + app.t("addTerminalTemplate") + } + } DialogKind::ReviewQueue => app.t("reviewQueue"), DialogKind::DebugLog => app.t("debugLog"), DialogKind::CreateGroup => app.t("createProjectGroup"), @@ -58,6 +215,7 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> move |_, _, cx| { view.update(cx, |app, cx| { app.data.overlay.dialog = None; + app.data.overlay.template_dialog_open = false; cx.notify(); }); } @@ -124,6 +282,7 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> move |_, _, cx| { view.update(cx, |app, cx| { app.data.overlay.dialog = None; + app.data.overlay.template_dialog_open = false; cx.notify(); }); } @@ -337,104 +496,137 @@ fn dialog_body( }) .map(|w| w.config.terminal_templates.clone()) .unwrap_or_default(); + let fallback = app.t("terminalTemplate"); v_flex() - .gap_2() - .child(div().text_sm().child(app.t("projectTerminalTemplates"))) + .gap_4() .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(app.t("projectTerminalTemplatesDescription")), + h_flex() + .items_start() + .justify_between() + .gap_4() + .child( + v_flex() + .gap_1() + .child(div().font_semibold().text_sm().child(app.t("projectTerminalTemplates"))) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("projectTerminalTemplatesDescription")), + ), + ) + .child( + Button::new("add-project-tpl") + .outline() + .small() + .label(app.t("addTerminalTemplate")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.open_project_template_create(window, cx); + cx.notify(); + }); + } + }), + ), ) .child(if templates.is_empty() { - div() - .p_2() - .text_sm() - .child(app.t("noTerminalTemplates")) + v_flex() + .min_h(px(96.)) + .items_center() + .justify_center() + .gap_1() + .child(div().font_medium().text_sm().child(app.t("noTerminalTemplates"))) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(app.t("projectTerminalTemplatesDescription")), + ) .into_any_element() } else { v_flex() - .gap_1() + .gap_2() .children(templates.into_iter().map(|t| { let id = t.id.clone(); + let preview = leftover_command_preview(&t.commands.join("\n")); + let name = leftover_template_display_name(&t.name, &fallback); h_flex() .id(crate::ui::eid(format!("ptpl-{id}"))) + .items_center() .justify_between() + .gap_4() + .rounded_lg() + .border_1() + .border_color(theme.border) + .px_4() + .py_3() .child( v_flex() - .id(crate::ui::eid(format!("ptpl-edit-{id}"))) - .cursor(gpui::CursorStyle::PointingHand) - .child(div().font_medium().text_sm().child(t.name.clone())) - .child( - div().text_xs().text_color(theme.muted_foreground).child(t.cwd.clone()), - ) - .on_click({ - let view = view.clone(); - let id = id.clone(); - move |_, window, cx| { - view.update(cx, |app, cx| { - app.load_project_template(&id, window, cx); - cx.notify(); - }); - } + .min_w_0() + .gap_1() + .child(div().font_medium().truncate().child(name)) + .when(!preview.is_empty(), |el| { + el.child( + div() + .font_family("monospace") + .text_sm() + .text_color(theme.muted_foreground) + .child(preview), + ) + }) + .when(!t.cwd.trim().is_empty(), |el| { + el.child( + div() + .font_family("monospace") + .text_xs() + .text_color(theme.muted_foreground) + .child(t.cwd.clone()), + ) }), ) .child( - Button::new(crate::ui::eid(format!("ptpl-del-{id}"))) - .danger() - .xsmall() - .label(app.t("delete")) - .tooltip(app.t("deleteTerminalTemplate")) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.remove_project_template(&id); - cx.notify(); - }); - } - }), + h_flex() + .flex_shrink_0() + .gap_1() + .child( + Button::new(crate::ui::eid(format!("ptpl-edit-{id}"))) + .ghost() + .xsmall() + .tooltip(app.t("editTerminalTemplate")) + .child(crate::ui::leftover_pencil_glyph(theme.muted_foreground)) + .on_click({ + let view = view.clone(); + let id = id.clone(); + move |_, window, cx| { + view.update(cx, |app, cx| { + app.load_project_template(&id, window, cx); + cx.notify(); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("ptpl-del-{id}"))) + .ghost() + .xsmall() + .tooltip(app.t("deleteTerminalTemplate")) + .child(Icon::new(IconName::Delete).w(px(14.)).text_color(theme.danger)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |app, cx| { + app.remove_project_template(&id); + cx.notify(); + }); + } + }), + ), ) })) .into_any_element() }) - .child(div().text_xs().child(app.t("terminalTemplateName"))) - .child(Input::new(&app.inputs.template_name)) - .child(div().text_xs().child(app.t("terminalTemplateCwd"))) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(app.t("terminalTemplateCwdDescription")), - ) - .child(Input::new(&app.inputs.template_cwd)) - .child(div().text_xs().child(app.t("terminalTemplateCommands"))) - .child( - div() - .text_xs() - .text_color(theme.muted_foreground) - .child(app.t("terminalTemplateCommandsDescription")), - ) - .child(Input::new(&app.inputs.template_commands)) - .child( - Button::new("add-project-tpl") - .xsmall() - .primary() - .label(if app.data.overlay.editing_template.is_some() { - app.t("save") - } else { - app.t("addTerminalTemplate") - }) - .on_click({ - let view = view.clone(); - move |_, _, cx| { - view.update(cx, |app, cx| { - app.add_project_template(cx); - cx.notify(); - }); - } - }), - ) .into_any_element() }) .into_any_element(), @@ -912,6 +1104,7 @@ fn dialog_footer(app: &AppView, kind: DialogKind, cx: &mut Context) -> view.update(cx, |app, cx| { app.data.overlay.dialog = None; app.data.overlay.open_link_menu = false; + app.data.overlay.template_dialog_open = false; cx.notify(); }); } diff --git a/src-gpui/src/ui/mod.rs b/src-gpui/src/ui/mod.rs index 9d3eaeb7..6beacd53 100644 --- a/src-gpui/src/ui/mod.rs +++ b/src-gpui/src/ui/mod.rs @@ -13,6 +13,10 @@ pub fn leftover_pencil_glyph(color: gpui::Hsla) -> gpui::Div { div().text_color(color).child("✎") } +pub fn leftover_wrench_glyph(color: gpui::Hsla) -> gpui::Div { + div().text_color(color).child("🔧") +} + pub fn leftover_house_glyph(color: gpui::Hsla) -> gpui::Div { div().text_color(color).child("⌂") } diff --git a/src-gpui/src/ui/settings.rs b/src-gpui/src/ui/settings.rs index f9f121d9..0a91a5d0 100644 --- a/src-gpui/src/ui/settings.rs +++ b/src-gpui/src/ui/settings.rs @@ -17,7 +17,10 @@ use gpui_component::tab::Tab; use crate::app::AppView; use crate::i18n::Locale; use crate::prefs::{term_theme_by_name, RadiusPref, TERM_THEMES, ThemePref}; -use crate::state::{DialogKind, SettingsTab}; +use crate::state::{ + leftover_command_preview, leftover_normalize_global_template, leftover_template_draft_can_save, + leftover_template_draft_width, DialogKind, SettingsTab, +}; pub fn open_update_page(app: &mut AppView, window: &mut Window, cx: &mut Context) { open_settings_at(app, window, cx, Some(SettingsTab::About)); @@ -106,6 +109,7 @@ pub struct SettingsView { fonts: Vec, sounds: Vec, editing_template_id: Option, + template_dialog_open: bool, preview_theme: Option, open_select: Option, } @@ -164,6 +168,7 @@ impl SettingsView { fonts: crate::platform::list_mono_fonts(), sounds: crate::platform::list_system_sounds(), editing_template_id: None, + template_dialog_open: false, preview_theme: None, open_select: None, } @@ -212,6 +217,7 @@ impl gpui::Render for SettingsView { v_flex() .id("settings-window") .size_full() + .relative() .bg(theme.background) .child( h_flex() @@ -244,6 +250,7 @@ impl gpui::Render for SettingsView { SettingsTab::About => self.about(cx).into_any_element(), }), ) + .child(self.leftover_template_draft_overlay(window, cx)) } } @@ -698,133 +705,318 @@ impl SettingsView { fn templates(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let _ = window; let view = cx.entity(); + let theme = cx.theme().clone(); v_flex() .id("settings-templates") .max_w(px(672.)) - .gap_3() - .child(div().font_semibold().child(self.t("globalTerminalTemplates"))) - .child(div().text_xs().text_color(cx.theme().muted_foreground).child(self.t("globalTerminalTemplatesDescription"))) + .gap_4() + .child( + h_flex() + .items_start() + .justify_between() + .gap_4() + .child( + v_flex() + .gap_1() + .child(div().font_semibold().child(self.t("globalTerminalTemplates"))) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(self.t("globalTerminalTemplatesDescription")), + ), + ) + .child( + Button::new("add-tpl") + .outline() + .small() + .label(self.t("addTerminalTemplate")) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| { + this.open_template_draft(None, window, cx); + }); + } + }), + ), + ) .child(if self.prefs.templates.is_empty() { - div().p_4().child(self.t("noTerminalTemplates")).into_any_element() + div() + .rounded_lg() + .border_1() + .border_color(theme.border) + .px_4() + .py_3() + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(self.t("noTerminalTemplates")), + ) + .into_any_element() } else { v_flex() .gap_2() .children(self.prefs.templates.iter().cloned().map(|t| { - let selected = self.editing_template_id.as_deref() == Some(t.id.as_str()); + let preview = leftover_command_preview(&t.commands.join("\n")); h_flex() .id(crate::ui::eid(format!("tpl-{}", t.id))) + .items_center() .justify_between() - .p_2() - .rounded_md() + .gap_4() + .rounded_lg() .border_1() - .border_color(if selected { - cx.theme().accent - } else { - cx.theme().border - }) + .border_color(theme.border) + .px_4() + .py_3() .child( v_flex() - .id(crate::ui::eid(format!("tpl-edit-{}", t.id))) - .cursor(gpui::CursorStyle::PointingHand) + .min_w_0() + .gap_1() .child(div().font_medium().child(t.name.clone())) - .child(div().text_xs().child(format!("{} · {}", t.shell, t.cwd))) - .on_click({ - let view = view.clone(); - let t = t.clone(); - move |_, window, cx| { - view.update(cx, |this, cx| { - this.editing_template_id = Some(t.id.clone()); - this.template_name.update(cx, |s, cx| { - s.set_value(t.name.clone(), window, cx); - }); - this.template_shell.update(cx, |s, cx| { - s.set_value(t.shell.clone(), window, cx); - }); - this.template_cwd.update(cx, |s, cx| { - s.set_value(t.cwd.clone(), window, cx); - }); - this.template_cmds.update(cx, |s, cx| { - s.set_value(t.commands.join("\n"), window, cx); - }); - cx.notify(); - }); - } + .when(!preview.is_empty(), |el| { + el.child( + div() + .font_family("monospace") + .text_sm() + .text_color(theme.muted_foreground) + .child(preview), + ) }), ) .child( - Button::new(crate::ui::eid(format!("del-{}", t.id))) - .danger() - .xsmall() - .label(self.t("delete")) - .tooltip(self.t("deleteTerminalTemplate")) + h_flex() + .flex_shrink_0() + .gap_1() + .child( + Button::new(crate::ui::eid(format!("tpl-edit-{}", t.id))) + .ghost() + .xsmall() + .tooltip(self.t("editTerminalTemplate")) + .child(crate::ui::leftover_pencil_glyph(theme.muted_foreground)) + .on_click({ + let view = view.clone(); + let t = t.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| { + this.open_template_draft(Some(&t), window, cx); + }); + } + }), + ) + .child( + Button::new(crate::ui::eid(format!("del-{}", t.id))) + .ghost() + .xsmall() + .tooltip(self.t("deleteTerminalTemplate")) + .child(Icon::new(IconName::Delete).w(px(14.)).text_color(theme.danger)) + .on_click({ + let view = view.clone(); + let id = t.id.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.prefs.templates.retain(|x| x.id != id); + if this.editing_template_id.as_deref() == Some(id.as_str()) { + this.close_template_draft(); + } + this.persist(cx); + cx.notify(); + }); + } + }), + ), + ) + })) + .into_any_element() + }) + } + + fn leftover_template_draft_overlay( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + if !self.template_dialog_open { + return div().id("no-tpl-draft").into_any_element(); + } + let view = cx.entity(); + let theme = cx.theme().clone(); + let editing = self.editing_template_id.is_some(); + let name = self.template_name.read(cx).value().to_string(); + let can_save = leftover_template_draft_can_save(&name); + div() + .id("tpl-draft-mask") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(gpui::hsla(0., 0., 0., 0.12)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.close_template_draft(); + cx.notify(); + }); + } + }) + .child( + v_flex() + .id("tpl-draft-panel") + .w(px(leftover_template_draft_width())) + .p_4() + .gap_4() + .rounded_xl() + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .shadow_lg() + .on_click(|_, _, _| {}) + .child( + h_flex() + .gap_2() + .items_center() + .child(Icon::new(IconName::SquareTerminal).w(px(16.))) + .child( + div().font_semibold().child(if editing { + self.t("editTerminalTemplate") + } else { + self.t("addTerminalTemplate") + }), + ), + ) + .child( + v_flex() + .gap_4() + .child( + v_flex() + .gap_1() + .child(field_label(&self.t("terminalTemplateName"))) + .child(Input::new(&self.template_name)), + ) + .child( + v_flex() + .gap_1() + .child(field_label(&self.t("terminalTemplateCommands"))) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(self.t("terminalTemplateCommandsDescription")), + ) + .child(Input::new(&self.template_cmds)), + ), + ) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_2() + .child( + Button::new("tpl-cancel") + .outline() + .small() + .label(self.t("cancel")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + this.close_template_draft(); + cx.notify(); + }); + } + }), + ) + .when(editing, |el| { + el.child( + Button::new("tpl-delete") + .danger() + .small() + .label(self.t("delete")) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |this, cx| { + if let Some(id) = this.editing_template_id.clone() { + this.prefs.templates.retain(|x| x.id != id); + this.persist(cx); + } + this.close_template_draft(); + cx.notify(); + }); + } + }), + ) + }), + ) + .child( + Button::new("tpl-save") + .primary() + .small() + .label(self.t("save")) + .disabled(!can_save) .on_click({ let view = view.clone(); - let id = t.id.clone(); move |_, _, cx| { view.update(cx, |this, cx| { - this.prefs.templates.retain(|x| x.id != id); - if this.editing_template_id.as_deref() == Some(id.as_str()) { - this.editing_template_id = None; - } - this.persist(cx); - cx.notify(); + this.save_template_draft(cx); }); } }), - ) - })) - .into_any_element() - }) - .child(field_label(&self.t("terminalTemplateName"))) - .child(Input::new(&self.template_name)) - .child(field_label(&self.t("terminalTemplateShell"))) - .child(Input::new(&self.template_shell)) - .child(field_label(&self.t("terminalTemplateCwd"))) - .child(Input::new(&self.template_cwd)) - .child(field_label(&self.t("terminalTemplateCommands"))) - .child(Input::new(&self.template_cmds)) - .child( - Button::new("add-tpl") - .primary() - .label(if self.editing_template_id.is_some() { - self.t("save") - } else { - self.t("addTerminalTemplate") - }) - .on_click({ - let view = view.clone(); - move |_, window, cx| { - view.update(cx, |this, cx| { - let name = this.template_name.read(cx).value().to_string(); - if name.trim().is_empty() { - return; - } - crate::prefs::upsert_template( - &mut this.prefs.templates, - this.editing_template_id.as_deref(), - name, - this.template_shell.read(cx).value().to_string(), - this.template_cwd.read(cx).value().to_string(), - this - .template_cmds - .read(cx) - .value() - .lines() - .map(|l| l.to_string()) - .filter(|l| !l.is_empty()) - .collect(), - ); - this.editing_template_id = None; - this.template_name.update(cx, |s, cx| s.set_value("", window, cx)); - this.template_shell.update(cx, |s, cx| s.set_value("", window, cx)); - this.template_cwd.update(cx, |s, cx| s.set_value("", window, cx)); - this.template_cmds.update(cx, |s, cx| s.set_value("", window, cx)); - this.persist(cx); - cx.notify(); - }); - } - }), + ), + ), ) + .into_any_element() + } + + fn open_template_draft( + &mut self, + template: Option<&crate::prefs::TerminalTemplatePref>, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(t) = template { + self.editing_template_id = Some(t.id.clone()); + self.template_name.update(cx, |s, cx| { + s.set_value(t.name.clone(), window, cx); + }); + self.template_cmds.update(cx, |s, cx| { + s.set_value(t.commands.join("\n"), window, cx); + }); + } else { + self.editing_template_id = None; + self.template_name.update(cx, |s, cx| s.set_value("", window, cx)); + self.template_cmds.update(cx, |s, cx| s.set_value("", window, cx)); + } + self.template_dialog_open = true; + cx.notify(); + } + + fn close_template_draft(&mut self) { + self.template_dialog_open = false; + self.editing_template_id = None; + } + + fn save_template_draft(&mut self, cx: &mut Context) { + let name = self.template_name.read(cx).value().to_string(); + let commands_text = self.template_cmds.read(cx).value().to_string(); + let Some((name, commands)) = leftover_normalize_global_template(&name, &commands_text) else { + return; + }; + crate::prefs::upsert_template( + &mut self.prefs.templates, + self.editing_template_id.as_deref(), + name, + String::new(), + String::new(), + commands, + ); + self.close_template_draft(); + self.persist(cx); + cx.notify(); } fn notification(&mut self, cx: &mut Context) -> impl IntoElement { @@ -1441,6 +1633,7 @@ fn set_tab( view.update(cx, |this, cx| { this.tab = tab; this.open_select = None; + this.template_dialog_open = false; cx.notify(); }); }) From b0c94b8dde655dc24e51546f7b4e2dc6b5160075 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 09:47:49 +0000 Subject: [PATCH 48/50] Match leftover tab icons, debug auto-scroll, and sticky folders. Tab icons now follow leftover title keywords only (otherwise TerminalWindow). Debug log tracks leftover 40px-from-bottom auto-scroll, and the file tree pins leftover sticky folders from visible rows plus scroll. Co-authored-by: AkaraChen --- src-gpui/src/app.rs | 3 + src-gpui/src/state.rs | 121 +++++++++++++++++++++++++++++++++-- src-gpui/src/ui/debug.rs | 23 ++++++- src-gpui/src/ui/file_tree.rs | 44 ++++++++----- 4 files changed, 169 insertions(+), 22 deletions(-) diff --git a/src-gpui/src/app.rs b/src-gpui/src/app.rs index f1024e36..8ded7a0d 100644 --- a/src-gpui/src/app.rs +++ b/src-gpui/src/app.rs @@ -390,6 +390,7 @@ impl AppView { tree_selected: HashSet::new(), tree_anchor: None, tree_error: None, + tree_scroll_y: 0.0, git_files: Vec::new(), git_included: HashSet::new(), git_stats: Default::default(), @@ -2708,6 +2709,8 @@ impl AppView { }); self.data.overlay.dialog = Some(DialogKind::DebugLog); self.data.overlay.debug_open = true; + self.data.overlay.debug_auto_scroll = true; + self.data.overlay.debug_scroll_y = 0.0; } pub fn toggle_debug_log(&mut self, window: &mut Window, cx: &mut Context) { diff --git a/src-gpui/src/state.rs b/src-gpui/src/state.rs index 782eb364..abbde281 100644 --- a/src-gpui/src/state.rs +++ b/src-gpui/src/state.rs @@ -120,7 +120,7 @@ impl AgentKind { } } - /// Inventory §9.1: tab icons come from the title keywords, then the detector. + /// Inventory §9.1: leftover tab icons come only from these title keywords. pub fn from_tab_title(title: &str) -> Option { let lower = title.to_ascii_lowercase(); const KEYS: &[(&str, AgentKind)] = &[ @@ -136,8 +136,8 @@ impl AgentKind { KEYS.iter().find(|(key, _)| lower.contains(key)).map(|(_, kind)| *kind) } - pub fn tab_icon_kind(title: &str, detected: Self) -> Self { - Self::from_tab_title(title).unwrap_or(detected) + pub fn tab_icon_kind(title: &str, _detected: Self) -> Self { + Self::from_tab_title(title).unwrap_or(Self::Unknown) } } @@ -523,6 +523,7 @@ pub struct Workspace { pub tree_selected: HashSet, pub tree_anchor: Option, pub tree_error: Option, + pub tree_scroll_y: f32, pub git_files: Vec<(String, String)>, pub git_included: HashSet, pub git_stats: GitDiffStats, @@ -617,6 +618,8 @@ pub struct OverlayState { pub settings_tab: SettingsTab, pub debug_open: bool, pub debug_logs: Vec, + pub debug_auto_scroll: bool, + pub debug_scroll_y: f32, pub collapsed_projects: HashSet, pub pending_close_file: Option, pub editing_template: Option, @@ -825,6 +828,54 @@ pub fn leftover_template_display_name(name: &str, fallback: &str) -> String { } } +pub fn leftover_debug_auto_scroll(scroll_height: f32, scroll_top: f32, client_height: f32) -> bool { + scroll_height - scroll_top - client_height < 40.0 +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LeftoverTreeRow { + pub path: String, + pub is_dir: bool, +} + +pub fn leftover_visible_tree_rows(tree: &HashMap) -> Vec { + let mut rows = Vec::new(); + fn walk(tree: &HashMap, path: &str, rows: &mut Vec) { + let Some(node) = tree.get(path) else { + return; + }; + if !path.is_empty() { + rows.push(LeftoverTreeRow { + path: path.to_string(), + is_dir: node.is_dir, + }); + } + if node.is_dir && node.expanded { + for child in &node.children { + walk(tree, child, rows); + } + } + } + walk(tree, "", &mut rows); + rows +} + +pub fn leftover_sticky_folder(rows: &[LeftoverTreeRow], scroll_y: f32, row_height: f32) -> Option { + if rows.is_empty() || row_height <= 0.0 { + return None; + } + let first_visible = ((scroll_y / row_height).floor() as usize).min(rows.len()); + rows[..first_visible] + .iter() + .rev() + .find(|row| row.is_dir) + .map(|row| row.path.clone()) +} + +pub fn leftover_tree_row_height() -> f32 { + 22.0 +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum LeftoverTemplateRow { EmptyTitle, @@ -1248,6 +1299,68 @@ mod tests { assert_eq!(leftover_template_display_name("Dev", "Terminal Template"), "Dev"); } + #[test] + fn leftover_debug_auto_scroll_and_sticky_folder_match_inventory() { + assert!(leftover_debug_auto_scroll(400.0, 360.0, 40.0)); + assert!(leftover_debug_auto_scroll(400.0, 361.0, 40.0)); + assert!(!leftover_debug_auto_scroll(400.0, 300.0, 40.0)); + let mut tree = HashMap::new(); + tree.insert( + String::new(), + TreeNode { + path: String::new(), + name: String::new(), + is_dir: true, + expanded: true, + children_loaded: true, + children: vec!["src".into(), "README.md".into()], + }, + ); + tree.insert( + "src".into(), + TreeNode { + path: "src".into(), + name: "src".into(), + is_dir: true, + expanded: true, + children_loaded: true, + children: vec!["src/lib.rs".into()], + }, + ); + tree.insert( + "src/lib.rs".into(), + TreeNode { + path: "src/lib.rs".into(), + name: "lib.rs".into(), + is_dir: false, + expanded: false, + children_loaded: true, + children: Vec::new(), + }, + ); + tree.insert( + "README.md".into(), + TreeNode { + path: "README.md".into(), + name: "README.md".into(), + is_dir: false, + expanded: false, + children_loaded: true, + children: Vec::new(), + }, + ); + let rows = leftover_visible_tree_rows(&tree); + assert_eq!( + rows.iter().map(|r| r.path.as_str()).collect::>(), + vec!["src", "src/lib.rs", "README.md"] + ); + assert_eq!(leftover_sticky_folder(&rows, 0.0, leftover_tree_row_height()), None); + assert_eq!( + leftover_sticky_folder(&rows, leftover_tree_row_height() + 1.0, leftover_tree_row_height()), + Some("src".into()) + ); + } + #[test] fn leftover_template_dropdown_matches_inventory() { assert_eq!( @@ -1351,7 +1464,7 @@ mod tests { assert_eq!(AgentKind::from_tab_title("opencode"), Some(AgentKind::OpenCode)); assert_eq!(AgentKind::from_tab_title("qoder"), Some(AgentKind::Qoder)); assert_eq!(AgentKind::from_tab_title("zsh"), None); - assert_eq!(AgentKind::tab_icon_kind("zsh", AgentKind::Cursor), AgentKind::Cursor); + assert_eq!(AgentKind::tab_icon_kind("zsh", AgentKind::Cursor), AgentKind::Unknown); assert_eq!(AgentKind::tab_icon_kind("claude", AgentKind::Codex), AgentKind::Claude); } diff --git a/src-gpui/src/ui/debug.rs b/src-gpui/src/ui/debug.rs index a643e4e5..f16dbdde 100644 --- a/src-gpui/src/ui/debug.rs +++ b/src-gpui/src/ui/debug.rs @@ -1,4 +1,4 @@ -use gpui::{div, prelude::*, px, Context, Window}; +use gpui::{div, prelude::*, px, Context, ScrollWheelEvent, Window}; use gpui_component::button::{Button, ButtonRounded, ButtonVariants}; use gpui_component::input::Input; use gpui_component::{h_flex, v_flex, ActiveTheme, IconName}; @@ -89,6 +89,8 @@ pub fn render_dialog_body(app: &mut AppView, cx: &mut Context) -> impl move |_, _, cx| { view.update(cx, |app, cx| { app.data.overlay.debug_logs.clear(); + app.data.overlay.debug_auto_scroll = true; + app.data.overlay.debug_scroll_y = 0.0; cx.notify(); }); } @@ -117,6 +119,25 @@ pub fn render_dialog_body(app: &mut AppView, cx: &mut Context) -> impl .w_full() .min_h(px(160.)) .overflow_y_scroll() + .on_scroll_wheel({ + let view = view.clone(); + let filtered = filtered; + move |ev: &ScrollWheelEvent, _, cx| { + view.update(cx, |app, cx| { + let delta = match ev.delta.pixel_delta(px(20.)) { + gpui::Point { y, .. } => f32::from(y), + }; + let content = (filtered as f32) * 20.0; + let viewport = 280.0; + let max_top = (content - viewport).max(0.0); + let scroll_top = (app.data.overlay.debug_scroll_y - delta).clamp(0.0, max_top); + app.data.overlay.debug_scroll_y = scroll_top; + app.data.overlay.debug_auto_scroll = + crate::state::leftover_debug_auto_scroll(content.max(viewport), scroll_top, viewport); + cx.notify(); + }); + } + }) .children(logs.into_iter().map(|l| { let (badge_bg, badge_fg) = leftover_level_colors(&l.level, &theme); h_flex() diff --git a/src-gpui/src/ui/file_tree.rs b/src-gpui/src/ui/file_tree.rs index 548ade18..3f1e0556 100644 --- a/src-gpui/src/ui/file_tree.rs +++ b/src-gpui/src/ui/file_tree.rs @@ -1,11 +1,12 @@ use std::time::Duration; -use gpui::{div, prelude::*, px, Animation, AnimationExt, Context, MouseButton, Window}; +use gpui::{div, prelude::*, px, Animation, AnimationExt, Context, MouseButton, ScrollWheelEvent, Window}; + use gpui_component::input::Input; use gpui_component::{h_flex, v_flex, ActiveTheme, Icon, IconName, StyledExt}; use crate::app::AppView; -use crate::state::ContextMenu; +use crate::state::{leftover_sticky_folder, leftover_tree_row_height, leftover_visible_tree_rows, ContextMenu}; use crate::ui::TreeDrag; pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -21,6 +22,7 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) v_flex() .id("file-tree") .size_full() + .min_h_0() .px(px(6.)) .py_1() .text_size(px(13.)) @@ -93,6 +95,24 @@ pub fn render(app: &mut AppView, window: &mut Window, cx: &mut Context) .child(app.t("fileTreeEmptyDirectory")) .into_any_element(), Some(root) => v_flex() + .id("file-tree-list") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .on_scroll_wheel({ + let view = view.clone(); + move |ev: &ScrollWheelEvent, _, cx| { + view.update(cx, |app, cx| { + if let Some(ws) = app.data.current_ws_mut() { + let delta = match ev.delta.pixel_delta(px(leftover_tree_row_height())) { + gpui::Point { y, .. } => f32::from(y), + }; + ws.tree_scroll_y = (ws.tree_scroll_y - delta).max(0.0); + cx.notify(); + } + }); + } + }) .children( root.children .iter() @@ -262,19 +282,9 @@ fn node_view( fn sticky_folder(app: &AppView) -> Option { let ws = app.data.current_ws()?; - let path = if let Some(selected) = ws.tree_selected.iter().next() { - let parent = selected.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(selected); - if parent.is_empty() { - None - } else { - Some(parent.to_string()) - } - } else { - ws.tree - .values() - .filter(|node| node.is_dir && node.expanded && !node.path.is_empty()) - .max_by_key(|node| node.path.matches('/').count()) - .map(|node| node.path.clone()) - }?; - Some(path) + leftover_sticky_folder( + &leftover_visible_tree_rows(&ws.tree), + ws.tree_scroll_y, + leftover_tree_row_height(), + ) } From 19bcb6223869f8a62418a909fdb12a0100240712 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 09:56:31 +0000 Subject: [PATCH 49/50] Harden leftover Windows PTY startup test. Poll for leftover tmpl-ok instead of a fixed 2s sleep so PowerShell init no longer races close. Also show leftover TerminalWindow on the Edit Template dialog title. Co-authored-by: AkaraChen --- src-gpui/src/ui/dialogs.rs | 3 +++ src-tauri/tests/integration_pty_db.rs | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src-gpui/src/ui/dialogs.rs b/src-gpui/src/ui/dialogs.rs index fb7e93e3..13ceba73 100644 --- a/src-gpui/src/ui/dialogs.rs +++ b/src-gpui/src/ui/dialogs.rs @@ -275,6 +275,9 @@ fn dialog(app: &mut AppView, window: &mut Window, cx: &mut Context) -> .when(kind == DialogKind::ChooseFile, |el| { el.child(Icon::new(IconName::File).w(px(16.))) }) + .when(kind == DialogKind::EditTemplate, |el| { + el.child(Icon::new(IconName::SquareTerminal).w(px(16.))) + }) .child(div().font_semibold().child(title)), ) .child(Button::new("dlg-x").ghost().xsmall().icon(IconName::Close).on_click({ diff --git a/src-tauri/tests/integration_pty_db.rs b/src-tauri/tests/integration_pty_db.rs index d2e11ac6..398cc731 100644 --- a/src-tauri/tests/integration_pty_db.rs +++ b/src-tauri/tests/integration_pty_db.rs @@ -92,6 +92,20 @@ fn wait_for_flush_sender(ctx: &PtyContext, session_id: &str) { panic!("flush sender was not registered for session {session_id}"); } +fn wait_for_history_contains(logs: &Path, session_id: &str, needle: &str) -> String { + let attempts = if cfg!(windows) { 80 } else { 40 }; + let mut history_text = String::new(); + for _ in 0..attempts { + let history = service::pty::get_history(logs, session_id); + history_text = String::from_utf8_lossy(&history).into_owned(); + if history_text.contains(needle) { + return history_text; + } + std::thread::sleep(Duration::from_millis(100)); + } + history_text +} + fn pty_config(cwd: String) -> PtyConfig { PtyConfig { shell: test_shell(), @@ -500,12 +514,11 @@ fn create_session_executes_startup_commands() { ) .unwrap(); - std::thread::sleep(std::time::Duration::from_secs(2)); + wait_for_flush_sender(&ctx, &session_id); + let history_text = wait_for_history_contains(&logs, &session_id, "tmpl-ok"); infra::pty::close_all_sessions(&sessions); infra::pty::join_all_read_threads(&read_threads); - let history = service::pty::get_history(&logs, &session_id); - let history_text = String::from_utf8_lossy(&history); assert!( history_text.contains("tmpl-ok"), "history did not contain startup output: {history_text:?}", From 162561fec20add21b605ad298db82d5461dee0cc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 3 Sep 2026 09:58:37 +0000 Subject: [PATCH 50/50] Match leftover notification defaults and topbar padding. New prefs start with leftover notifications off and leftover Ping sound. Top bar right padding is leftover 20px, or leftover 118px on Windows. Co-authored-by: AkaraChen --- src-gpui/src/prefs.rs | 11 +++++++++-- src-gpui/src/ui/workspace.rs | 14 ++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src-gpui/src/prefs.rs b/src-gpui/src/prefs.rs index d3004de3..57d4e7f0 100644 --- a/src-gpui/src/prefs.rs +++ b/src-gpui/src/prefs.rs @@ -114,8 +114,8 @@ impl Default for Prefs { font_size: 13.0, default_shell: String::new(), custom_shell: String::new(), - notifications: true, - notification_sound: String::new(), + notifications: false, + notification_sound: "Ping".into(), topbar_controls: default_topbar_controls(), editor_app: "code".into(), terminal_app: String::new(), @@ -252,6 +252,13 @@ mod tests { assert_eq!(controls[1], "terminal"); } + #[test] + fn leftover_notification_defaults_match_inventory() { + let prefs = Prefs::default(); + assert!(!prefs.notifications); + assert_eq!(prefs.notification_sound, "Ping"); + } + #[test] fn missing_show_all_fonts_defaults_false() { let raw = r#"{"language":"en","theme":"system","radius":"medium","worktree_dir":"","debug_mode":false,"performance_profile":false,"show_avatars":true,"sidebar_width":250.0,"sidebar_collapsed":false,"collapsed_groups":[],"profile_sidebar_width":208.0,"profile_sidebar_open":true,"terminal_theme_dark":"GitHub Dark","terminal_theme_light":"GitHub Light","sync_terminal_theme":false,"font_family":"Menlo","font_size":13.0,"default_shell":"","custom_shell":"","notifications":true,"notification_sound":"","topbar_controls":[],"editor_app":"","terminal_app":"","accept_beta":false,"templates":[],"collapsed_projects":[]}"#; diff --git a/src-gpui/src/ui/workspace.rs b/src-gpui/src/ui/workspace.rs index f6ab9ff6..69e2bbcd 100644 --- a/src-gpui/src/ui/workspace.rs +++ b/src-gpui/src/ui/workspace.rs @@ -54,7 +54,7 @@ fn topbar(app: &mut AppView, _window: &mut Window, cx: &mut Context) -> } else { px(16.) }; - let pad_right = if cfg!(target_os = "windows") { px(118.) } else { px(16.) }; + let pad_right = px(leftover_topbar_pad_right(cfg!(target_os = "windows"))); let controls = app.data.prefs.topbar_controls.clone(); div() @@ -200,6 +200,10 @@ pub fn leftover_show_git_diff_stats(insertions: u32, deletions: u32) -> bool { insertions != 0 || deletions != 0 } +pub fn leftover_topbar_pad_right(windows: bool) -> f32 { + if windows { 118.0 } else { 20.0 } +} + fn mode_switch( app: &AppView, mode: SidebarMode, @@ -1001,7 +1005,7 @@ fn persistent_terminals(app: &mut AppView, window: &mut Window, cx: &mut Context #[cfg(test)] mod tests { - use super::leftover_show_git_diff_stats; + use super::{leftover_show_git_diff_stats, leftover_topbar_pad_right}; #[test] fn leftover_git_stats_hide_clean_trees() { @@ -1010,4 +1014,10 @@ mod tests { assert!(leftover_show_git_diff_stats(0, 4)); assert!(leftover_show_git_diff_stats(2, 3)); } + + #[test] + fn leftover_topbar_pad_right_matches_inventory() { + assert_eq!(leftover_topbar_pad_right(true), 118.0); + assert_eq!(leftover_topbar_pad_right(false), 20.0); + } }