From 284d0167b5f80b40288e5a6f6d4921b7b1431d11 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Thu, 30 Apr 2026 23:35:42 +0900 Subject: [PATCH 01/15] feat(native): replace kibo UI diffs with monaco diff editor showing full file diffs, with scroll to hunks --- apps/native/.storybook/mocks/tauri-runtime.ts | 1 + .../src-tauri/examples/specta_gen_ts.rs | 3 +- apps/native/src-tauri/src/commands.rs | 17 + apps/native/src-tauri/src/git.rs | 14 + apps/native/src-tauri/src/main.rs | 1 + apps/native/src-tauri/src/shared_types.rs | 8 + apps/native/src-tauri/src/summarize/sumlog.rs | 4 +- apps/native/src/components/code-example4.tsx | 306 --------- apps/native/src/components/editor-panel.tsx | 59 +- .../components/kibo-ui/code-block/index.tsx | 582 ------------------ .../components/kibo-ui/code-block/server.tsx | 63 -- .../src/components/kibo-ui/tree/index.tsx | 445 ------------- .../__snapshots__/nix-editor.stories.tsx.snap | 7 + .../{kibo-ui => }/nix-editor/index.tsx | 0 .../nix-editor/nix-editor.stories.tsx | 0 .../nix-editor/use-nix-editor.test.ts | 0 .../nix-editor/use-nix-editor.ts | 0 .../collapsible-diff.stories.tsx.snap | 11 + .../diff-section.stories.tsx.snap | 3 + .../full-file-diff-editor.stories.tsx.snap | 9 + .../__snapshots__/hunk-pill.stories.tsx.snap | 9 + .../summaries/collapsible-diff.stories.tsx | 91 +++ .../widget/summaries/collapsible-diff.tsx | 79 +++ .../widget/summaries/diff-section.stories.tsx | 94 +++ .../widget/summaries/diff-section.tsx | 68 ++ .../src/components/widget/summaries/diff.tsx | 107 ---- .../full-file-diff-editor.stories.tsx | 156 +++++ .../summaries/full-file-diff-editor.tsx | 147 +++++ .../widget/summaries/hunk-pill.stories.tsx | 73 +++ .../components/widget/summaries/hunk-pill.tsx | 60 ++ .../widget/summaries/monaco-setup.ts | 56 ++ .../widget/summaries/summary-or-diff.tsx | 26 +- .../unsummarized-changes-section.tsx | 2 +- apps/native/src/components/widget/utils.ts | 20 +- apps/native/src/index.css | 38 ++ apps/native/src/tauri-api.ts | 3 + apps/native/src/types/shared.ts | 5 + 37 files changed, 1016 insertions(+), 1551 deletions(-) delete mode 100644 apps/native/src/components/code-example4.tsx delete mode 100644 apps/native/src/components/kibo-ui/code-block/index.tsx delete mode 100644 apps/native/src/components/kibo-ui/code-block/server.tsx delete mode 100644 apps/native/src/components/kibo-ui/tree/index.tsx create mode 100644 apps/native/src/components/nix-editor/__snapshots__/nix-editor.stories.tsx.snap rename apps/native/src/components/{kibo-ui => }/nix-editor/index.tsx (100%) rename apps/native/src/components/{kibo-ui => }/nix-editor/nix-editor.stories.tsx (100%) rename apps/native/src/components/{kibo-ui => }/nix-editor/use-nix-editor.test.ts (100%) rename apps/native/src/components/{kibo-ui => }/nix-editor/use-nix-editor.ts (100%) create mode 100644 apps/native/src/components/widget/summaries/__snapshots__/collapsible-diff.stories.tsx.snap create mode 100644 apps/native/src/components/widget/summaries/__snapshots__/diff-section.stories.tsx.snap create mode 100644 apps/native/src/components/widget/summaries/__snapshots__/full-file-diff-editor.stories.tsx.snap create mode 100644 apps/native/src/components/widget/summaries/__snapshots__/hunk-pill.stories.tsx.snap create mode 100644 apps/native/src/components/widget/summaries/collapsible-diff.stories.tsx create mode 100644 apps/native/src/components/widget/summaries/collapsible-diff.tsx create mode 100644 apps/native/src/components/widget/summaries/diff-section.stories.tsx create mode 100644 apps/native/src/components/widget/summaries/diff-section.tsx delete mode 100644 apps/native/src/components/widget/summaries/diff.tsx create mode 100644 apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx create mode 100644 apps/native/src/components/widget/summaries/full-file-diff-editor.tsx create mode 100644 apps/native/src/components/widget/summaries/hunk-pill.stories.tsx create mode 100644 apps/native/src/components/widget/summaries/hunk-pill.tsx create mode 100644 apps/native/src/components/widget/summaries/monaco-setup.ts diff --git a/apps/native/.storybook/mocks/tauri-runtime.ts b/apps/native/.storybook/mocks/tauri-runtime.ts index 35874b48a..944a81aa4 100644 --- a/apps/native/.storybook/mocks/tauri-runtime.ts +++ b/apps/native/.storybook/mocks/tauri-runtime.ts @@ -254,6 +254,7 @@ export const storybookDarwinAPI = { cached: async () => baseGitStatus(), commit: async () => ({ hash: "mock123", evolveState: baseEvolveState() }), stash: async () => undefined, + fileDiffContents: async (_filenames: string[]) => ({}), stageAll: async () => undefined, unstageAll: async () => undefined, restoreAll: async () => undefined, diff --git a/apps/native/src-tauri/examples/specta_gen_ts.rs b/apps/native/src-tauri/examples/specta_gen_ts.rs index 3c9706ff3..fed90b376 100644 --- a/apps/native/src-tauri/examples/specta_gen_ts.rs +++ b/apps/native/src-tauri/examples/specta_gen_ts.rs @@ -54,7 +54,8 @@ fn main() { .register::() .register::() .register::() - .register::(); + .register::() + .register::(); let shared_output_path = "../src/types/shared.ts"; diff --git a/apps/native/src-tauri/src/commands.rs b/apps/native/src-tauri/src/commands.rs index e94cbcd2a..be9276c81 100644 --- a/apps/native/src-tauri/src/commands.rs +++ b/apps/native/src-tauri/src/commands.rs @@ -349,6 +349,23 @@ pub async fn git_stash(app: AppHandle, message: String) -> Result, +) -> Result, String> { + let dir = + store::get_config_dir(&app).map_err(|e| capture_err("git_file_diff_contents", e))?; + Ok(filenames + .into_iter() + .map(|f| { + let (original, modified) = git::file_diff_contents(&dir, &f); + (f, shared_types::FileDiffContents { original, modified }) + }) + .collect()) +} + // ============================================================================= // Darwin/Nix Commands // ============================================================================= diff --git a/apps/native/src-tauri/src/git.rs b/apps/native/src-tauri/src/git.rs index b9fec37c5..c1cafef50 100644 --- a/apps/native/src-tauri/src/git.rs +++ b/apps/native/src-tauri/src/git.rs @@ -115,6 +115,20 @@ pub fn get_nix_diff(dir: &str) -> Result { Ok(diff) } +/// Returns (original, modified) file content for a single file: HEAD content and working-tree content. +/// Returns empty strings for new files (no HEAD) or deleted files (not on disk). +pub fn file_diff_contents(dir: &str, filename: &str) -> (String, String) { + let original = git_command() + .args(["show", &format!("HEAD:{filename}")]) + .current_dir(dir) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) + .unwrap_or_default(); + let modified = std::fs::read_to_string(std::path::Path::new(dir).join(filename)) + .unwrap_or_default(); + (original, modified) +} + /// Returns a diff of tracked changes, optionally restricted to a path glob. /// Falls back to staged+unstaged when HEAD is unborn (fresh repo with no commits). fn get_tracked_diff(dir: &str, path_filter: Option<&str>) -> Result { diff --git a/apps/native/src-tauri/src/main.rs b/apps/native/src-tauri/src/main.rs index 000b768b5..56cc744c1 100644 --- a/apps/native/src-tauri/src/main.rs +++ b/apps/native/src-tauri/src/main.rs @@ -328,6 +328,7 @@ fn run_gui_mode( commands::git_cached, commands::git_commit, commands::git_stash, + commands::git_file_diff_contents, // Darwin/Nix commands::darwin_evolve, commands::darwin_evolve_cancel, diff --git a/apps/native/src-tauri/src/shared_types.rs b/apps/native/src-tauri/src/shared_types.rs index a922fb29f..2325082ff 100644 --- a/apps/native/src-tauri/src/shared_types.rs +++ b/apps/native/src-tauri/src/shared_types.rs @@ -9,6 +9,14 @@ use crate::sqlite_types::{Change, ChangeSet, ChangeSummary}; // Git status types // ============================================================================= +/// HEAD content vs working-tree content for a file, used by the diff tab Monaco DiffEditor. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FileDiffContents { + pub original: String, + pub modified: String, +} + /// Type of change for a file in git status. #[derive(Debug, Clone, Copy, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] diff --git a/apps/native/src-tauri/src/summarize/sumlog.rs b/apps/native/src-tauri/src/summarize/sumlog.rs index 2928e53f1..368ca7043 100644 --- a/apps/native/src-tauri/src/summarize/sumlog.rs +++ b/apps/native/src-tauri/src/summarize/sumlog.rs @@ -9,8 +9,8 @@ pub const FIND_EXISTING: bool = false; pub const GROUP_EXISTING: bool = false; pub const SIMPLIFY_GROUP: bool = false; -pub const FRESH_CHANGESET: bool = false; -pub const EVOLVED_CHANGESET: bool = false; +pub const FRESH_CHANGESET: bool = true; +pub const EVOLVED_CHANGESET: bool = true; pub const QUEUE_SUMMARIZER: bool = false; // ── Imports ─────────────────────────────────────────────────────────────────── diff --git a/apps/native/src/components/code-example4.tsx b/apps/native/src/components/code-example4.tsx deleted file mode 100644 index 68f6ec77c..000000000 --- a/apps/native/src/components/code-example4.tsx +++ /dev/null @@ -1,306 +0,0 @@ -"use client"; - -import { ArrowRightIcon } from "lucide-react"; -import * as React from "react"; -import { - type BundledLanguage, - CodeBlock, - CodeBlockBody, - CodeBlockContent, - CodeBlockCopyButton, - CodeBlockFilename, - CodeBlockHeader, - CodeBlockItem, -} from "@/components/kibo-ui/code-block"; -import { - TreeExpander, - TreeIcon, - TreeLabel, - TreeNode, - TreeNodeContent, - TreeNodeTrigger, - TreeProvider, - TreeView, -} from "@/components/kibo-ui/tree"; -import { Button } from "@/components/ui/button"; -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; -import { cn } from "@/lib/utils"; - -// Type definitions -interface FileNode { - id: string; - name: string; - type: "file" | "folder"; - language?: string; - code?: string; - children?: FileNode[]; -} - -// Payload CMS file structure data -const fileStructure: FileNode[] = [ - { - id: "src", - name: "src", - type: "folder", - children: [ - { - id: "components", - name: "components", - type: "folder", - children: [ - { - id: "ui", - name: "ui", - type: "folder", - children: [ - { - id: "button.tsx", - name: "button.tsx", - type: "file", - language: "typescript", - code: `import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: - "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", - destructive: - "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - secondary: - "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", - ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - link: "text-primary underline-offset-4 hover:underline", - }, - size: { - default: "h-9 px-4 py-2 has-[>svg]:px-3", - sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - icon: "size-9", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Button({ - className, - variant, - size, - asChild = false, - ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean - }) { - const Comp = asChild ? Slot : "button" - - return ( - - ) -} - -export { Button, buttonVariants }`, - }, - ], - }, - ], - }, - ], - }, - { - id: "tsconfig.json", - name: "tsconfig.json", - type: "file", - language: "json", - code: `{ - "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -}`, - }, -]; - -// Helper function to find file by ID -const findFileById = (files: FileNode[], id: string): FileNode | null => { - for (const file of files) { - if (file.id === id) { - return file; - } - if (file.children) { - const found = findFileById(file.children, id); - if (found) return found; - } - } - return null; -}; - -// Render tree nodes recursively -const renderTreeNodes = (nodes: FileNode[], level = 0) => - nodes.map((node, index) => { - const isLast = index === nodes.length - 1; - const hasChildren = node.children && node.children.length > 0; - - return ( - - - - - {node.name} - - {hasChildren && node.children && ( - - {renderTreeNodes(node.children, level + 1)} - - )} - - ); - }); - -interface CodeExample4Props { - className?: string; -} - -const CodeExample4 = ({ className }: CodeExample4Props) => { - const [selectedFileId, setSelectedFileId] = - React.useState("button.tsx"); - const [selectedFile, setSelectedFile] = React.useState(() => - findFileById(fileStructure, "button.tsx") - ); - - const handleFileSelection = React.useCallback((fileIds: string[]) => { - if (fileIds.length > 0) { - const fileId = fileIds[0]; - const file = findFileById(fileStructure, fileId); - if (file && file.type === "file") { - setSelectedFileId(fileId); - setSelectedFile(file); - } - } - }, []); - - const codeData = React.useMemo(() => { - if (!selectedFile) return []; - return [ - { - language: selectedFile.language || "typescript", - filename: selectedFile.name, - code: selectedFile.code || "// No content available", - }, - ]; - }, [selectedFile]); - - return ( -
-
-
- - Component Library - -

Rich, reusable components

-

- Discover our comprehensive collection of production-ready - components. Browse through the file tree to explore different - implementations, patterns, and best practices for building modern - web applications. -

-
- -
- {/* File Tree */} -
- - {renderTreeNodes(fileStructure)} - -
- - {/* Code Block */} -
- - - - {selectedFile?.name || "Select a file"} - - - - - - {(item) => ( - - - {item.code} - - - )} - - - - -
-
- -
- -
-
-
- ); -}; - -export { CodeExample4 }; diff --git a/apps/native/src/components/editor-panel.tsx b/apps/native/src/components/editor-panel.tsx index 8c647fbd3..98abadffd 100644 --- a/apps/native/src/components/editor-panel.tsx +++ b/apps/native/src/components/editor-panel.tsx @@ -1,7 +1,28 @@ import { X } from "lucide-react"; -import { NixEditor } from "@/components/kibo-ui/nix-editor"; +import { NixEditor } from "@/components/nix-editor"; import { Button } from "@/components/ui/button"; import { useWidgetStore } from "@/stores/widget-store"; +import { Component, type ErrorInfo, type ReactNode } from "react"; + +class EditorErrorBoundary extends Component< + { children: ReactNode; onError: () => void }, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(_error: Error, _info: ErrorInfo) { + this.props.onError(); + } + + render() { + if (this.state.hasError) return null; + return this.props.children; + } +} export function EditorPanel() { const editingFile = useWidgetStore((s) => s.editingFile); @@ -13,24 +34,26 @@ export function EditorPanel() { const filename = editingFile.split("/").pop() ?? editingFile; return ( -
-
-
- Editing - {filename} - ({editingFile}) + +
+
+
+ Editing + {filename} + ({editingFile}) +
+
- + { + // Could trigger a git status refresh here in the future + }} + />
- { - // Could trigger a git status refresh here in the future - }} - /> -
+ ); } diff --git a/apps/native/src/components/kibo-ui/code-block/index.tsx b/apps/native/src/components/kibo-ui/code-block/index.tsx deleted file mode 100644 index 24d8de820..000000000 --- a/apps/native/src/components/kibo-ui/code-block/index.tsx +++ /dev/null @@ -1,582 +0,0 @@ -"use client"; - -import { useControllableState } from "@radix-ui/react-use-controllable-state"; -import { - transformerNotationDiff, - transformerNotationErrorLevel, - transformerNotationFocus, - transformerNotationHighlight, - transformerNotationWordHighlight, -} from "@shikijs/transformers"; -import { CheckIcon, CopyIcon } from "lucide-react"; -import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react"; -import { cloneElement, createContext, useContext, useEffect, useState } from "react"; -import type { IconType } from "react-icons"; -import { - SiAstro, - SiBiome, - SiBower, - SiBun, - SiC, - SiCircleci, - SiCoffeescript, - SiCplusplus, - SiCss, - SiCssmodules, - SiDart, - SiDocker, - SiDocusaurus, - SiDotenv, - SiEditorconfig, - SiEslint, - SiGatsby, - SiGitignoredotio, - SiGnubash, - SiGo, - SiGraphql, - SiGrunt, - SiGulp, - SiHandlebarsdotjs, - SiHtml5, - SiJavascript, - SiJest, - SiJson, - SiLess, - SiMarkdown, - SiMdx, - SiMintlify, - SiMocha, - SiMysql, - SiNextdotjs, - SiPerl, - SiPhp, - SiPostcss, - SiPrettier, - SiPrisma, - SiPug, - SiPython, - SiR, - SiReact, - SiReadme, - SiRedis, - SiRemix, - SiRive, - SiRollupdotjs, - SiRuby, - SiSanity, - SiSass, - SiScala, - SiSentry, - SiShadcnui, - SiStorybook, - SiStylelint, - SiSublimetext, - SiSvelte, - SiSvg, - SiSwift, - SiTailwindcss, - SiToml, - SiTypescript, - SiVercel, - SiVite, - SiVuedotjs, - SiWebassembly, -} from "react-icons/si"; -import { type BundledLanguage, type CodeOptionsMultipleThemes, codeToHtml } from "shiki"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; - -export type { BundledLanguage } from "shiki"; - -const filenameIconMap = { - ".env": SiDotenv, - "*.astro": SiAstro, - "biome.json": SiBiome, - ".bowerrc": SiBower, - "bun.lockb": SiBun, - "*.c": SiC, - "*.cpp": SiCplusplus, - ".circleci/config.yml": SiCircleci, - "*.coffee": SiCoffeescript, - "*.module.css": SiCssmodules, - "*.css": SiCss, - "*.dart": SiDart, - Dockerfile: SiDocker, - "docusaurus.config.js": SiDocusaurus, - ".editorconfig": SiEditorconfig, - ".eslintrc": SiEslint, - "eslint.config.*": SiEslint, - "gatsby-config.*": SiGatsby, - ".gitignore": SiGitignoredotio, - "*.go": SiGo, - "*.graphql": SiGraphql, - "*.sh": SiGnubash, - "Gruntfile.*": SiGrunt, - "gulpfile.*": SiGulp, - "*.hbs": SiHandlebarsdotjs, - "*.html": SiHtml5, - "*.js": SiJavascript, - "*.json": SiJson, - "*.test.js": SiJest, - "*.less": SiLess, - "*.md": SiMarkdown, - "*.mdx": SiMdx, - "mintlify.json": SiMintlify, - "mocha.opts": SiMocha, - "*.mustache": SiHandlebarsdotjs, - "*.sql": SiMysql, - "next.config.*": SiNextdotjs, - "*.pl": SiPerl, - "*.php": SiPhp, - "postcss.config.*": SiPostcss, - "prettier.config.*": SiPrettier, - "*.prisma": SiPrisma, - "*.pug": SiPug, - "*.py": SiPython, - "*.r": SiR, - "*.rb": SiRuby, - "*.jsx": SiReact, - "*.tsx": SiReact, - "readme.md": SiReadme, - "*.rdb": SiRedis, - "remix.config.*": SiRemix, - "*.riv": SiRive, - "rollup.config.*": SiRollupdotjs, - "sanity.config.*": SiSanity, - "*.sass": SiSass, - "*.scss": SiSass, - "*.sc": SiScala, - "*.scala": SiScala, - "sentry.client.config.*": SiSentry, - "components.json": SiShadcnui, - "storybook.config.*": SiStorybook, - "stylelint.config.*": SiStylelint, - ".sublime-settings": SiSublimetext, - "*.svelte": SiSvelte, - "*.svg": SiSvg, - "*.swift": SiSwift, - "tailwind.config.*": SiTailwindcss, - "*.toml": SiToml, - "*.ts": SiTypescript, - "vercel.json": SiVercel, - "vite.config.*": SiVite, - "*.vue": SiVuedotjs, - "*.wasm": SiWebassembly, -}; - -const lineNumberClassNames = cn( - "[&_code]:[counter-reset:line]", - "[&_code]:[counter-increment:line_0]", - "[&_.line]:before:content-[counter(line)]", - "[&_.line]:before:inline-block", - "[&_.line]:before:[counter-increment:line]", - "[&_.line]:before:w-4", - "[&_.line]:before:mr-4", - "[&_.line]:before:text-[13px]", - "[&_.line]:before:text-right", - "[&_.line]:before:text-muted-foreground/50", - "[&_.line]:before:font-mono", - "[&_.line]:before:select-none", -); - -const darkModeClassNames = cn( - "dark:[&_.shiki]:!text-[var(--shiki-dark)]", - // "dark:[&_.shiki]:!bg-[var(--shiki-dark-bg)]", - "dark:[&_.shiki]:![font-style:var(--shiki-dark-font-style)]", - "dark:[&_.shiki]:![font-weight:var(--shiki-dark-font-weight)]", - "dark:[&_.shiki]:![text-decoration:var(--shiki-dark-text-decoration)]", - "dark:[&_.shiki_span]:!text-[var(--shiki-dark)]", - "dark:[&_.shiki_span]:![font-style:var(--shiki-dark-font-style)]", - "dark:[&_.shiki_span]:![font-weight:var(--shiki-dark-font-weight)]", - "dark:[&_.shiki_span]:![text-decoration:var(--shiki-dark-text-decoration)]", -); - -const lineHighlightClassNames = cn( - "[&_.line.highlighted]:bg-blue-50", - "[&_.line.highlighted]:after:bg-blue-500", - "[&_.line.highlighted]:after:absolute", - "[&_.line.highlighted]:after:left-0", - "[&_.line.highlighted]:after:top-0", - "[&_.line.highlighted]:after:bottom-0", - "[&_.line.highlighted]:after:w-0.5", - "dark:[&_.line.highlighted]:!bg-blue-500/10", -); - -const lineDiffClassNames = cn( - "[&_.line.diff]:after:absolute", - "[&_.line.diff]:after:left-0", - "[&_.line.diff]:after:top-0", - "[&_.line.diff]:after:bottom-0", - "[&_.line.diff]:after:w-0.5", - "[&_.line.diff.add]:bg-emerald-50", - "[&_.line.diff.add]:after:bg-emerald-500", - "[&_.line.diff.remove]:bg-rose-50", - "[&_.line.diff.remove]:after:bg-rose-500", - "dark:[&_.line.diff.add]:!bg-emerald-500/10", - "dark:[&_.line.diff.remove]:!bg-rose-500/10", -); - -const lineFocusedClassNames = cn( - "[&_code:has(.focused)_.line]:blur-[2px]", - "[&_code:has(.focused)_.line.focused]:blur-none", -); - -const wordHighlightClassNames = cn( - "[&_.highlighted-word]:bg-blue-50", - "dark:[&_.highlighted-word]:!bg-blue-500/10", -); - -const codeBlockClassName = cn( - "mt-0 bg-background text-xs", - "[&_pre]:py-4", - // "[&_.shiki]:!bg-[var(--shiki-bg)]", - "[&_.shiki]:!bg-transparent", - "[&_code]:min-w-full", - "[&_code]:grid", - "[&_code]:overflow-x-auto", - "[&_code]:bg-transparent", - "[&_.line]:px-4", - "[&_.line]:min-w-full", - "[&_.line]:relative", -); - -const highlight = ( - html: string, - language?: BundledLanguage, - themes?: CodeOptionsMultipleThemes["themes"], -) => - codeToHtml(html, { - lang: language ?? "typescript", - themes: themes ?? { - light: "github-light", - dark: "github-dark-default", - }, - transformers: [ - transformerNotationDiff({ - matchAlgorithm: "v3", - }), - transformerNotationHighlight({ - matchAlgorithm: "v3", - }), - transformerNotationWordHighlight({ - matchAlgorithm: "v3", - }), - transformerNotationFocus({ - matchAlgorithm: "v3", - }), - transformerNotationErrorLevel({ - matchAlgorithm: "v3", - }), - ], - }); - -type CodeBlockData = { - language: string; - filename: string; - code: string; -}; - -type CodeBlockContextType = { - value: string | undefined; - onValueChange: ((value: string) => void) | undefined; - data: CodeBlockData[]; -}; - -const CodeBlockContext = createContext({ - value: undefined, - onValueChange: undefined, - data: [], -}); - -export type CodeBlockProps = HTMLAttributes & { - defaultValue?: string; - value?: string; - onValueChange?: (value: string) => void; - data: CodeBlockData[]; -}; - -export const CodeBlock = ({ - value: controlledValue, - onValueChange: controlledOnValueChange, - defaultValue, - className, - data, - ...props -}: CodeBlockProps) => { - const [value, onValueChange] = useControllableState({ - defaultProp: defaultValue ?? "", - prop: controlledValue, - onChange: controlledOnValueChange, - }); - - return ( - -
- - ); -}; - -export type CodeBlockHeaderProps = HTMLAttributes; - -export const CodeBlockHeader = ({ className, ...props }: CodeBlockHeaderProps) => ( -
-); - -export type CodeBlockFilesProps = Omit, "children"> & { - children: (item: CodeBlockData) => ReactNode; -}; - -export const CodeBlockFiles = ({ className, children, ...props }: CodeBlockFilesProps) => { - const { data } = useContext(CodeBlockContext); - - return ( -
- {data.map(children)} -
- ); -}; - -export type CodeBlockFilenameProps = HTMLAttributes & { - icon?: IconType; - value?: string; -}; - -export const CodeBlockFilename = ({ - className, - icon, - value, - children, - ...props -}: CodeBlockFilenameProps) => { - const { value: activeValue } = useContext(CodeBlockContext); - const defaultIcon = Object.entries(filenameIconMap).find(([pattern]) => { - const regex = new RegExp( - `^${pattern.replace(/\\/g, "\\\\").replace(/\./g, "\\.").replace(/\*/g, ".*")}$`, - ); - return regex.test(children as string); - })?.[1]; - const Icon = icon ?? defaultIcon; - - if (value !== activeValue) { - return null; - } - - return ( -
- {Icon && } - {children} -
- ); -}; - -export type CodeBlockSelectProps = ComponentProps; - -export const CodeBlockSelect = (props: CodeBlockSelectProps) => { - const { value, onValueChange } = useContext(CodeBlockContext); - - return
"`; + +exports[`Single Hunk 1`] = `"
"`; diff --git a/apps/native/src/components/widget/summaries/__snapshots__/hunk-pill.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/hunk-pill.stories.tsx.snap new file mode 100644 index 000000000..533a2600a --- /dev/null +++ b/apps/native/src/components/widget/summaries/__snapshots__/hunk-pill.stories.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Additions Only 1`] = `"+3"`; + +exports[`Deletions Only 1`] = `"-3"`; + +exports[`Mixed 1`] = `"+2 -1"`; + +exports[`With Summary Title 1`] = `"Add vim and git"`; diff --git a/apps/native/src/components/widget/summaries/collapsible-diff.stories.tsx b/apps/native/src/components/widget/summaries/collapsible-diff.stories.tsx new file mode 100644 index 000000000..fb1947850 --- /dev/null +++ b/apps/native/src/components/widget/summaries/collapsible-diff.stories.tsx @@ -0,0 +1,91 @@ +// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) +import preview from "#storybook/preview"; +import type { ChangeWithRichType } from "@/components/widget/utils"; +import { CollapsibleDiff } from "./collapsible-diff"; + +const meta = preview.meta({ + title: "Widget/Summaries/CollapsibleDiff", + component: CollapsibleDiff, + parameters: { layout: "padded" }, + tags: ["autodocs"], +}); + +export default meta; + +function makeChange( + changeType: ChangeWithRichType["changeType"], + filename: string, +): ChangeWithRichType { + return { + id: 1, + hash: "abc123", + filename, + diff: "", + lineCount: 4, + createdAt: Date.now(), + ownSummaryId: null, + changeType, + shortFilename: filename.split("/").pop() ?? filename, + }; +} + +export const CollapsedEdited = meta.story({ + render: () => ( +
+ +
Diff content here
+
+
+ ), +}); + +export const OpenNew = meta.story({ + render: () => ( +
+ +
Diff content here
+
+
+ ), +}); + +export const Removed = meta.story({ + render: () => ( +
+ +
Diff content here
+
+
+ ), +}); + +export const Renamed = meta.story({ + render: () => ( +
+ +
Diff content here
+
+
+ ), +}); + +export const WithHeaderExtra = meta.story({ + render: () => ( +
+ +3 -1 + } + > +
Diff content here
+
+
+ ), +}); diff --git a/apps/native/src/components/widget/summaries/collapsible-diff.tsx b/apps/native/src/components/widget/summaries/collapsible-diff.tsx new file mode 100644 index 000000000..2fe55e3ff --- /dev/null +++ b/apps/native/src/components/widget/summaries/collapsible-diff.tsx @@ -0,0 +1,79 @@ +import { + Collapsible, + CollapsibleContent, +} from "@/components/ui/collapsible"; +import { + CHANGE_TYPE_STYLES, + getDirectory, + getShortFilename, + type ChangeWithRichType, +} from "@/components/widget/utils"; +import { useWidgetStore } from "@/stores/widget-store"; +import { ChevronRight, Pencil } from "lucide-react"; + +interface CollapsibleDiffProps { + change: ChangeWithRichType; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + onToggle?: () => void; + headerExtra?: React.ReactNode; + children: React.ReactNode; +} + +export function CollapsibleDiff({ + change, + open, + defaultOpen, + onOpenChange, + onToggle, + headerExtra, + children, +}: CollapsibleDiffProps) { + const { icon: Icon, iconColor } = CHANGE_TYPE_STYLES[change.changeType]; + const dir = getDirectory(change.filename); + const name = getShortFilename(change.filename); + + return ( + +
+ + +
+ + {dir && {dir}/} + {name} + + {headerExtra && ( +
{headerExtra}
+ )} +
+ +
+ +
{children}
+
+
+ ); +} diff --git a/apps/native/src/components/widget/summaries/diff-section.stories.tsx b/apps/native/src/components/widget/summaries/diff-section.stories.tsx new file mode 100644 index 000000000..e6125a98d --- /dev/null +++ b/apps/native/src/components/widget/summaries/diff-section.stories.tsx @@ -0,0 +1,94 @@ +// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) +import preview from "#storybook/preview"; +import type { Change } from "@/types/shared"; +import { DiffSection } from "./diff-section"; + +const meta = preview.meta({ + title: "Widget/Summaries/DiffSection", + component: DiffSection, + parameters: { layout: "padded" }, + tags: ["autodocs"], +}); + +export default meta; + +// ============================================================================= +// Mock data +// ============================================================================= + +function makeChange(id: number, filename: string, diff: string): Change { + return { + id, + hash: `hash${id}`, + filename, + diff, + lineCount: diff.split("\n").length, + createdAt: Date.now(), + ownSummaryId: null, + }; +} + +const packagesDiff = `diff --git a/modules/darwin/packages.nix b/modules/darwin/packages.nix +--- a/modules/darwin/packages.nix ++++ b/modules/darwin/packages.nix +@@ -3,6 +3,8 @@ + environment.systemPackages = with pkgs; [ + vim + git ++ ripgrep ++ fd + ];`; + +const shellDiff = `diff --git a/modules/home/shell.nix b/modules/home/shell.nix +new file mode 100644 +--- /dev/null ++++ b/modules/home/shell.nix +@@ -0,0 +1,5 @@ ++{ config, pkgs, ... }: ++{ ++ programs.zsh.enable = true; ++ programs.starship.enable = true; ++}`; + +const flakeDiff = `diff --git a/flake.nix b/flake.nix +--- a/flake.nix ++++ b/flake.nix +@@ -5,6 +5,7 @@ + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + darwin.url = "github:LnL7/nix-darwin/master"; ++ home-manager.url = "github:nix-community/home-manager"; + };`; + +// ============================================================================= +// Stories +// ============================================================================= + +export const SingleFile = meta.story({ + render: () => ( +
+ +
+ ), +}); + +export const MultipleFiles = meta.story({ + render: () => ( +
+ +
+ ), +}); + +export const Empty = meta.story({ + render: () => ( +
+ +
+ ), +}); diff --git a/apps/native/src/components/widget/summaries/diff-section.tsx b/apps/native/src/components/widget/summaries/diff-section.tsx new file mode 100644 index 000000000..f2ac10267 --- /dev/null +++ b/apps/native/src/components/widget/summaries/diff-section.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + enrichChanges, + type ChangeWithRichType, +} from "@/components/widget/utils"; +import { darwinAPI } from "@/tauri-api"; +import type { Change, FileDiffContents } from "@/types/shared"; +import { useEffect, useMemo, useState } from "react"; +import { FullFileDiffEditor } from "./full-file-diff-editor"; + +interface DiffSectionProps { + changes: Change[]; +} + +export function DiffSection({ changes }: DiffSectionProps) { + const [fileContents, setFileContents] = useState>({}); + + const enriched = useMemo(() => enrichChanges(changes), [changes]); + + const byFile = useMemo(() => { + const map = new Map(); + for (const c of enriched) { + const arr = map.get(c.filename) ?? []; + arr.push(c); + map.set(c.filename, arr); + } + return map; + }, [enriched]); + + const filenames = useMemo(() => [...byFile.keys()], [byFile]); + const filenamesKey = filenames.join(","); + useEffect(() => { + if (filenames.length === 0) { + setFileContents({}); + return; + } + darwinAPI.git + .fileDiffContents(filenames) + .then(setFileContents) + .catch(() => setFileContents({})); + }, [filenamesKey]); + + if (changes.length === 0) { + return ( +
+ No diff available +
+ ); + } + + return ( + +
+ {[...byFile.entries()].map(([filename, fileChanges], index) => ( + + ))} +
+
+ ); +} diff --git a/apps/native/src/components/widget/summaries/diff.tsx b/apps/native/src/components/widget/summaries/diff.tsx deleted file mode 100644 index ccd988862..000000000 --- a/apps/native/src/components/widget/summaries/diff.tsx +++ /dev/null @@ -1,107 +0,0 @@ -"use client"; - -import { ChevronRight, Pencil } from "lucide-react"; -import type { BundledLanguage } from "shiki"; -import { - CodeBlock, - CodeBlockBody, - CodeBlockContent, - CodeBlockItem, -} from "@/components/kibo-ui/code-block"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { useWidgetStore } from "@/stores/widget-store"; -import { CHANGE_TYPE_STYLES, getDirectory, getShortFilename, type ChangeWithRichType } from "@/components/widget/utils"; - -interface DiffProps { - changes: ChangeWithRichType[]; -} - -export function Diff({ changes }: DiffProps) { - if (changes.length === 0) { - return ( -
- No diff available -
- ); - } - - return ( - -
- {changes.map((change, index) => { - const { icon: Icon, iconColor } = CHANGE_TYPE_STYLES[change.changeType]; - const dir = getDirectory(change.filename); - const name = getShortFilename(change.filename); - const codeData = [ - { - language: "diff", - filename: change.filename, - code: change.diff, - }, - ]; - - return ( - - {/* File header */} -
- - - - -
- - {dir && {dir}/} - {name} - -
- -
- - {/* Code diff */} - -
- - - {(item) => ( - - - {item.code} - - - )} - - -
-
-
- ); - })} -
-
- ); -} diff --git a/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx b/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx new file mode 100644 index 000000000..a488bc685 --- /dev/null +++ b/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx @@ -0,0 +1,156 @@ +// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) +import preview from "#storybook/preview"; +import { useWidgetStore } from "@/stores/widget-store"; +import type { ChangeWithRichType } from "@/components/widget/utils"; +import type { FileDiffContents } from "@/types/shared"; +import { useEffect } from "react"; +import { FullFileDiffEditor } from "./full-file-diff-editor"; + +const meta = preview.meta({ + title: "Widget/Summaries/FullFileDiffEditor", + component: FullFileDiffEditor, + parameters: { layout: "padded" }, + tags: ["autodocs"], +}); + +export default meta; + +// ============================================================================= +// Mock data +// ============================================================================= + +const ORIGINAL = `{ config, pkgs, ... }: + +{ + environment.systemPackages = with pkgs; [ + vim + git + ]; + + services.nix-daemon.enable = true; +}`; + +const MODIFIED = `{ config, pkgs, ... }: + +{ + environment.systemPackages = with pkgs; [ + vim + git + ripgrep + fd + jq + ]; + + services.nix-daemon.enable = true; + nix.settings.experimental-features = [ "nix-command" "flakes" ]; +}`; + +const DIFF_HEADER = `diff --git a/configuration.nix b/configuration.nix +--- a/configuration.nix ++++ b/configuration.nix +@@ -4,6 +4,9 @@ + environment.systemPackages = with pkgs; [ + vim + git ++ ripgrep ++ fd ++ jq + ];`; + +const DIFF_HEADER_2 = `diff --git a/configuration.nix b/configuration.nix +--- a/configuration.nix ++++ b/configuration.nix +@@ -9,4 +12,5 @@ + services.nix-daemon.enable = true; ++ nix.settings.experimental-features = [ "nix-command" "flakes" ]; + }`; + +function makeChange(id: number, diff: string): ChangeWithRichType { + return { + id, + hash: `hash${id}`, + filename: "configuration.nix", + diff, + lineCount: diff.split("\n").length, + createdAt: Date.now(), + ownSummaryId: null, + changeType: "edited", + shortFilename: "configuration.nix", + }; +} + +const mockContents: FileDiffContents = { + original: ORIGINAL, + modified: MODIFIED, +}; + +const changeMap = { + groups: [{ + summary: { id: 1, title: "Add CLI tools", description: "", status: "DONE", createdAt: 0 }, + changes: [{ hash: "hash1", title: "Add ripgrep, fd, jq", description: "", id: 1, filename: "configuration.nix", diff: "", lineCount: 0, createdAt: 0, ownSummaryId: null }], + }], + singles: [{ hash: "hash2", title: "Enable flakes", description: "", id: 2, filename: "configuration.nix", diff: "", lineCount: 0, createdAt: 0, ownSummaryId: null }], + unsummarizedHashes: [], +}; + +function WithStore({ children }: { children: React.ReactNode }) { + useEffect(() => { + useWidgetStore.getState().setChangeMap(changeMap); + }, []); + return
{children}
; +} + +// ============================================================================= +// Stories +// ============================================================================= + +export const SingleHunk = meta.story({ + render: () => ( + + + + ), +}); + +export const MultipleHunks = meta.story({ + render: () => ( + + + + ), +}); + +export const Collapsed = meta.story({ + render: () => ( + + + + ), +}); + +export const Loading = meta.story({ + render: () => ( + + + + ), +}); diff --git a/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx b/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx new file mode 100644 index 000000000..15e65b566 --- /dev/null +++ b/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx @@ -0,0 +1,147 @@ +import { getShortFilename, type ChangeWithRichType } from "@/components/widget/utils"; +import type { FileDiffContents } from "@/types/shared"; +import { DiffEditor } from "@monaco-editor/react"; +import type { editor } from "monaco-editor"; +import { useEffect, useRef, useState } from "react"; +import { CollapsibleDiff } from "./collapsible-diff"; +import { HunkPill } from "./hunk-pill"; +import { DIFF_EDITOR_OPTIONS, monaco } from "./monaco-setup"; + +function getModStartLine(diff: string): number | null { + const match = /@@ -\d+(?:,\d+)? \+(\d+)/.exec(diff); + return match ? parseInt(match[1]) : null; +} + +// ============================================================================= +// FullFileDiffEditor +// ============================================================================= + +interface FullFileDiffEditorProps { + filename: string; + changes: ChangeWithRichType[]; + contents?: FileDiffContents; + defaultOpen?: boolean; +} + +export function FullFileDiffEditor({ filename, changes, contents, defaultOpen }: FullFileDiffEditorProps) { + const editorRef = useRef(null); + const [isOpen, setIsOpen] = useState(defaultOpen ?? false); + + const scrollToChange = (index: number) => { + const line = getModStartLine(changes[index].diff); + if (line) editorRef.current?.getModifiedEditor().revealLineInCenter(line, monaco.editor.ScrollType.Smooth); + }; + + const handlePillClick = (index: number) => { + if (!isOpen) { + setIsOpen(true); + setTimeout(() => scrollToChange(index), 150); + } else { + scrollToChange(index); + } + }; + + const handleToggle = () => { + if (!isOpen) { + setIsOpen(true); + setTimeout(() => scrollToChange(0), 150); + } else { + setIsOpen(false); + editorRef.current?.setModel(null); + } + }; + + const handleEditorMount = (ed: editor.IStandaloneDiffEditor) => { + editorRef.current = ed; + }; + + const displayChange: ChangeWithRichType = { + ...changes[0], + changeType: "edited", + shortFilename: getShortFilename(filename), + hasMultipleHunks: true, + }; + + return ( + + {changes.map((c, i) => ( + handlePillClick(i)} /> + ))} + + } + > + {contents ? ( + + ) : ( +
+ Loading... +
+ )} +
+ ); +} + +// ============================================================================= +// InlineDiffEditor (internal) +// ============================================================================= + +interface InlineDiffEditorProps { + contents: FileDiffContents; + filename: string; + onMount: (editor: editor.IStandaloneDiffEditor) => void; +} + +function InlineDiffEditor({ contents, filename, onMount }: InlineDiffEditorProps) { + const disposableRef = useRef(null); + const lineCount = Math.max( + contents.original.split("\n").length, + contents.modified.split("\n").length, + ); + + useEffect(() => { + return () => { + disposableRef.current?.dispose(); + disposableRef.current = null; + }; + }, []); + + return ( + { + onMount(ed); + ed.getOriginalEditor().updateOptions({ lineNumbers: "off" }); + + const decorate = () => { + try { + const diffs = ed.getLineChanges() ?? []; + ed.getModifiedEditor().createDecorationsCollection( + diffs + .filter((d) => d.modifiedEndLineNumber > 0) + .map((d) => ({ + range: new monaco.Range(d.modifiedStartLineNumber, 1, d.modifiedEndLineNumber, 10000), + options: { + inlineClassName: "nixmac-line-added", + linesDecorationsClassName: "nixmac-gutter-added", + }, + })), + ); + } catch { /* editor disposed */ } + }; + + disposableRef.current = ed.onDidUpdateDiff(decorate); + decorate(); + }} + /> + ); +} diff --git a/apps/native/src/components/widget/summaries/hunk-pill.stories.tsx b/apps/native/src/components/widget/summaries/hunk-pill.stories.tsx new file mode 100644 index 000000000..4558087be --- /dev/null +++ b/apps/native/src/components/widget/summaries/hunk-pill.stories.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) +import preview from "#storybook/preview"; +import { useWidgetStore } from "@/stores/widget-store"; +import type { ChangeWithRichType } from "@/components/widget/utils"; +import type { SemanticChangeMap } from "@/types/shared"; +import { useEffect } from "react"; +import { HunkPill } from "./hunk-pill"; + +const meta = preview.meta({ + title: "Widget/Summaries/HunkPill", + component: HunkPill, + parameters: { layout: "centered" }, + tags: ["autodocs"], +}); + +export default meta; + +function makeChange(overrides: Partial & { diff: string }): ChangeWithRichType { + return { + id: 1, + hash: "abc123", + filename: "modules/darwin/packages.nix", + lineCount: 4, + createdAt: Date.now(), + ownSummaryId: null, + changeType: "edited", + shortFilename: "packages.nix", + ...overrides, + }; +} + +const changeMap: SemanticChangeMap = { + groups: [{ + summary: { id: 1, title: "Add system packages", description: "", status: "DONE", createdAt: 0 }, + changes: [{ hash: "with-summary", title: "Add vim and git", description: "", id: 1, filename: "", diff: "", lineCount: 0, createdAt: 0, ownSummaryId: null }], + }], + singles: [], + unsummarizedHashes: [], +}; + +function WithStore({ change, map }: { change: ChangeWithRichType; map?: SemanticChangeMap }) { + useEffect(() => { + if (map) useWidgetStore.getState().setChangeMap(map); + }, []); + return {}} />; +} + +export const AdditionsOnly = meta.story({ + render: () => ( + + ), +}); + +export const DeletionsOnly = meta.story({ + render: () => ( + + ), +}); + +export const Mixed = meta.story({ + render: () => ( + + ), +}); + +export const WithSummaryTitle = meta.story({ + render: () => ( + + ), +}); diff --git a/apps/native/src/components/widget/summaries/hunk-pill.tsx b/apps/native/src/components/widget/summaries/hunk-pill.tsx new file mode 100644 index 000000000..9280b379a --- /dev/null +++ b/apps/native/src/components/widget/summaries/hunk-pill.tsx @@ -0,0 +1,60 @@ +import { Badge } from "@/components/ui/badge"; +import type { ChangeWithRichType } from "@/components/widget/utils"; +import { useWidgetStore } from "@/stores/widget-store"; + +function getDiffBody(diff: string): string[] { + const lines = diff.split("\n"); + const hunkStart = lines.findIndex((l) => l.startsWith("@@")); + return hunkStart >= 0 ? lines.slice(hunkStart + 1) : []; +} + +function countAddedRemoved(diff: string): { added: number; removed: number } { + const body = getDiffBody(diff); + let added = 0; + let removed = 0; + for (const line of body) { + if (line.startsWith("+") && !line.startsWith("+++")) added++; + else if (line.startsWith("-") && !line.startsWith("---")) removed++; + } + return { added, removed }; +} + +interface HunkPillProps { + change: ChangeWithRichType; + onClick: () => void; +} + +export function HunkPill({ change, onClick }: HunkPillProps) { + const changeMap = useWidgetStore((s) => s.changeMap); + + let summaryTitle: string | null = null; + if (changeMap) { + for (const group of changeMap.groups) { + const match = group.changes.find((c) => c.hash === change.hash); + if (match) { summaryTitle = match.title; break; } + } + if (!summaryTitle) { + const match = changeMap.singles.find((c) => c.hash === change.hash); + if (match) summaryTitle = match.title; + } + } + + const { added, removed } = countAddedRemoved(change.diff); + + const label = summaryTitle + ?? [added && `+${added}`, removed && `-${removed}`].filter(Boolean).join(" "); + + return ( + { + e.stopPropagation(); + onClick(); + }} + title={label} + > + {label} + + ); +} \ No newline at end of file diff --git a/apps/native/src/components/widget/summaries/monaco-setup.ts b/apps/native/src/components/widget/summaries/monaco-setup.ts new file mode 100644 index 000000000..c7becc5c1 --- /dev/null +++ b/apps/native/src/components/widget/summaries/monaco-setup.ts @@ -0,0 +1,56 @@ +import { loader } from "@monaco-editor/react"; +import * as monaco from "monaco-editor"; + +// Use the locally bundled monaco-editor instead of CDN (required in Tauri offline context) +loader.config({ monaco }); + +monaco.editor.defineTheme("nixmac-dark", { + base: "vs-dark", + inherit: true, + rules: [ + { token: "addition.diff", foreground: "34d399" }, + { token: "deletion.diff", foreground: "f87171" }, + { token: "info.diff", foreground: "64748b" }, + ], + colors: { + "diffEditor.insertedLineBackground": "#34d39920", + "diffEditor.removedLineBackground": "#f8717120", + "diffEditor.insertedTextBackground": "#00000000", + "diffEditor.removedTextBackground": "#00000000", + "diffEditorGutter.insertedLineBackground": "#00000000", + "diffEditorGutter.removedLineBackground": "#00000000", + }, +}); + +export const DIFF_EDITOR_OPTIONS = { + readOnly: true, + minimap: { enabled: false }, + renderLineHighlight: "none" as const, + overviewRulerLanes: 0, + overviewRulerBorder: false, + hideCursorInOverviewRuler: true, + scrollbar: { + vertical: "auto" as const, + horizontal: "hidden" as const, + handleMouseWheel: true, + alwaysConsumeMouseWheel: false, + verticalScrollbarSize: 8, + horizontalScrollbarSize: 0, + }, + wordWrap: "off" as const, + fontSize: 12, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + smoothScrolling: true, + scrollBeyondLastLine: false, + folding: false, + glyphMargin: false, + automaticLayout: true, + padding: { top: 8, bottom: 8 }, + guides: { indentation: false, bracketPairs: false }, + renderSideBySide: false, + renderOverviewRuler: false, + renderIndicators: true, +}; + +export { monaco }; diff --git a/apps/native/src/components/widget/summaries/summary-or-diff.tsx b/apps/native/src/components/widget/summaries/summary-or-diff.tsx index 74f7f0f95..795b5ea9c 100644 --- a/apps/native/src/components/widget/summaries/summary-or-diff.tsx +++ b/apps/native/src/components/widget/summaries/summary-or-diff.tsx @@ -5,7 +5,7 @@ import { AnimatedTabsTrigger, } from "@/components/ui/animated-tabs"; import { Tabs } from "@/components/ui/tabs"; -import { Diff } from "@/components/widget/summaries/diff"; +import { DiffSection } from "@/components/widget/summaries/diff-section"; import { SummaryItems } from "@/components/widget/summaries/summary-items"; import { useSummary } from "@/hooks/use-summary"; import { cn } from "@/lib/utils"; @@ -25,6 +25,7 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { const evolveState = useWidgetStore((s) => s.evolveState); const { summarizeOnFocus } = useSummary(); const [activeTab, setActiveTab] = useState("summary"); + const [diffMounted, setDiffMounted] = useState(false); useEffect(() => { window.addEventListener("focus", summarizeOnFocus); @@ -34,11 +35,17 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { if (!gitStatus || !evolveState || evolveState.step === "begin") { return null; } - const changes = enrichChanges(gitStatus.changes) ?? []; - const handleTabChange = (tab: string) => { - setActiveTab(tab); - if (tab === "summary") summarizeOnFocus(); + if (tab !== "diff") { + // Unmount editors first, then switch tab next frame + requestAnimationFrame(() => { + setActiveTab(tab); + if (tab === "summary") summarizeOnFocus(); + }); + } else { + setActiveTab(tab); + if (!diffMounted) setDiffMounted(true); + } }; const hashSet = new Set(changeMap?.unsummarizedHashes); @@ -82,9 +89,12 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { /> )} - - - + {diffMounted && ( + // Monaco crashes on unmount, so we just hide it +
+ +
+ )} ); diff --git a/apps/native/src/components/widget/summaries/unsummarized-changes-section.tsx b/apps/native/src/components/widget/summaries/unsummarized-changes-section.tsx index e0c54b359..088ba2e84 100644 --- a/apps/native/src/components/widget/summaries/unsummarized-changes-section.tsx +++ b/apps/native/src/components/widget/summaries/unsummarized-changes-section.tsx @@ -23,7 +23,7 @@ export function UnsummarizedChangesSection({
{changesWithRenamed.map((item) => ( ))} diff --git a/apps/native/src/components/widget/utils.ts b/apps/native/src/components/widget/utils.ts index d22511310..dd773110d 100644 --- a/apps/native/src/components/widget/utils.ts +++ b/apps/native/src/components/widget/utils.ts @@ -44,23 +44,6 @@ export function getDirectory(path: string): string { return parts.slice(0, -1).join("/"); } -/** - * Infer change type from diff chunk content. - */ -export function getChangeTypeFromChunks( - chunks: string, -): "new" | "edited" | "removed" { - const contentLines = chunks - .split("\n") - .filter((l) => l.startsWith("+") || l.startsWith("-")); - if (contentLines.length === 0) return "edited"; - const hasAdditions = contentLines.some((l) => l.startsWith("+")); - const hasDeletions = contentLines.some((l) => l.startsWith("-")); - if (hasAdditions && !hasDeletions) return "new"; - if (!hasAdditions && hasDeletions) return "removed"; - return "edited"; -} - // ============================================================================= // SUMMARY CATEGORY COLORS // ============================================================================= @@ -206,12 +189,13 @@ export type ChangeWithRichType = Change & { changeType: ChangeType; oldFilename?: string; shortFilename?: string; + hasMultipleHunks?: boolean; }; export function inferChangeType(diff: string): ChangeType { if (/^new file mode/m.test(diff)) return "new"; if (/^deleted file mode/m.test(diff)) return "removed"; - return getChangeTypeFromChunks(diff) as ChangeType; + return "edited"; } export type RenamePair = { diff --git a/apps/native/src/index.css b/apps/native/src/index.css index 966469a2f..1ce1e50ba 100644 --- a/apps/native/src/index.css +++ b/apps/native/src/index.css @@ -74,6 +74,44 @@ @apply text-foreground; } + /* Monaco diff text colors (emerald-400 / red-400) */ + .nixmac-line-added, + .nixmac-line-added * { + color: #34d399 !important; + } + /* Deleted lines in inline diff are rendered as view zones, not model lines */ + .monaco-editor .view-zones .view-line, + .monaco-editor .view-zones .view-line * { + color: #f87171 !important; + } + + /* Diff gutter +/- indicator colors (match line text) */ + .monaco-diff-editor .codicon-diff-insert, + .monaco-diff-editor .margin .insert-sign { + background: #34d39930; + color: #34d399 !important; + } + .monaco-diff-editor .codicon-diff-remove, + .monaco-diff-editor .margin .delete-sign { + background: #f8717130; + color: #f87171 !important; + } + /* Hide the bleeding delete indicator inside the margin overlay */ + .monaco-editor .margin-view-overlays .cldr.delete-sign.codicon-diff-remove, + .monaco-editor .margin-view-overlays .cmdr.gutter-delete { + display: none !important; + } + + /* Monaco scrollbar matching nixmac style */ + .monaco-scrollable-element > .scrollbar.vertical > .slider { + background: hsl(173 80% 75% / 0.6) !important; + border-radius: 4px !important; + } + .monaco-scrollable-element > .scrollbar.vertical > .slider:hover, + .monaco-scrollable-element > .scrollbar.vertical.active > .slider { + background: hsl(173 80% 75%) !important; + } + /* Scrollbar styling */ ::-webkit-scrollbar { width: 8px; diff --git a/apps/native/src/tauri-api.ts b/apps/native/src/tauri-api.ts index b0626a5ac..e2f94f87c 100644 --- a/apps/native/src/tauri-api.ts +++ b/apps/native/src/tauri-api.ts @@ -7,6 +7,7 @@ import { import type { EvolutionResult, EvolveState, + FileDiffContents, GitStatus, HistoryItem, RollbackResult, @@ -23,6 +24,7 @@ export type { EvolutionTelemetry, EvolveState, EvolveStep, + FileDiffContents, GitFileStatus, GitStatus, HistoryItem, @@ -234,6 +236,7 @@ export const darwinAPI = { cached: () => invoke("git_cached"), commit: (message: string) => invoke("git_commit", { message }), stash: (message: string) => invoke("git_stash", { message }), + fileDiffContents: (filenames: string[]) => invoke>("git_file_diff_contents", { filenames }), }, darwin: { evolve: (description: string) => invoke("darwin_evolve", { description }), diff --git a/apps/native/src/types/shared.ts b/apps/native/src/types/shared.ts index 5d45efb5a..13721ecdd 100644 --- a/apps/native/src/types/shared.ts +++ b/apps/native/src/types/shared.ts @@ -93,6 +93,11 @@ rollbackBranch: string | null; rollbackStorePath: string | null; rollbackChanges */ export type EvolveStep = "begin" | "evolve" | "commit" | "manualEvolve" | "manualCommit" +/** + * HEAD content vs working-tree content for a file, used by the diff tab Monaco DiffEditor. + */ +export type FileDiffContents = { original: string; modified: string } + /** * Individual file status parsed from diff headers. */ From 8e59a0c22750b3e1e944b6f793a94c31766e9543 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Tue, 5 May 2026 16:05:53 +0900 Subject: [PATCH 02/15] fix(widget) improve highlighting for monaco diff editors, add a nixmac theme from claude design and follow console errors to fix monaco editors with error supression off --- apps/native/package.json | 3 +- apps/native/src-tauri/src/commands/git.rs | 2 +- apps/native/src-tauri/src/editor/lsp.rs | 3 + .../components/nix-editor/use-nix-editor.ts | 3 +- .../widget/summaries/collapsible-diff.tsx | 24 +++--- .../widget/summaries/diff-editor.tsx | 64 ++++++++++++++ .../summaries/full-file-diff-editor.tsx | 86 +++---------------- .../components/widget/summaries/hunk-pill.tsx | 6 +- .../widget/summaries/monaco-setup.ts | 85 ++++++++++++++++-- .../widget/summaries/plain-editor.tsx | 25 ++++++ .../widget/summaries/summary-or-diff.tsx | 22 +---- apps/native/src/components/widget/utils.ts | 4 +- apps/native/src/components/widget/widget.tsx | 3 +- apps/native/src/lib/lsp-client.ts | 23 ++++- apps/native/src/lib/nix-grammar.ts | 11 +-- bun.lock | 9 +- 16 files changed, 241 insertions(+), 132 deletions(-) create mode 100644 apps/native/src/components/widget/summaries/diff-editor.tsx create mode 100644 apps/native/src/components/widget/summaries/plain-editor.tsx diff --git a/apps/native/package.json b/apps/native/package.json index cfa117082..4fc377530 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -42,6 +42,7 @@ "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.0", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-radio-group": "^1.3.8", @@ -53,7 +54,6 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", - "@radix-ui/react-dropdown-menu": "^2.1.0", "@sentry/react": "^10.38.0", "@shikijs/transformers": "^3.20.0", "@tanstack/react-form": "^1.28.0", @@ -68,6 +68,7 @@ "monaco-editor-textmate": "^4.0.0", "monaco-textmate": "^3.0.1", "motion": "^12.35.2", + "onigasm": "^2.2.5", "react": "^19.2.3", "react-dom": "^19.1.0", "react-icons": "^5.5.0", diff --git a/apps/native/src-tauri/src/commands/git.rs b/apps/native/src-tauri/src/commands/git.rs index 5081a0142..7a943bc41 100644 --- a/apps/native/src-tauri/src/commands/git.rs +++ b/apps/native/src-tauri/src/commands/git.rs @@ -23,7 +23,7 @@ pub async fn git_file_diff_contents( Ok(filenames .into_iter() .map(|f| { - let (original, modified) = git::file_diff_contents(&dir, &f); + let (original, modified) = git::exec::file_diff_contents(&dir, &f); (f, shared_types::FileDiffContents { original, modified }) }) .collect()) diff --git a/apps/native/src-tauri/src/editor/lsp.rs b/apps/native/src-tauri/src/editor/lsp.rs index 233a4fd98..831922daa 100644 --- a/apps/native/src-tauri/src/editor/lsp.rs +++ b/apps/native/src-tauri/src/editor/lsp.rs @@ -77,6 +77,9 @@ pub async fn start(app: &AppHandle) -> Result<(), String> { } } } + // Clear state for next open + *lsp_state().lock().await = None; + let _ = app_handle.emit("lsp:exit", ()); }); // Spawn stderr reader — log for debugging. diff --git a/apps/native/src/components/nix-editor/use-nix-editor.ts b/apps/native/src/components/nix-editor/use-nix-editor.ts index af8e23060..a763c6d32 100644 --- a/apps/native/src/components/nix-editor/use-nix-editor.ts +++ b/apps/native/src/components/nix-editor/use-nix-editor.ts @@ -4,6 +4,7 @@ import { initNixGrammar } from "@/lib/nix-grammar"; import { lspClient } from "@/lib/lsp-client"; import { bridgeMonacoToLsp } from "@/lib/lsp-monaco-bridge"; import { darwinAPI } from "@/tauri-api"; +import { NIXMAC_THEME } from "@/components/widget/summaries/monaco-setup"; interface UseNixEditorOptions { filePath: string; @@ -74,7 +75,7 @@ export function useNixEditor({ filePath, containerRef, onSave, disabled = false editor = monaco.editor.create(container!, { value: content, language, - theme: "vs-dark", + theme: NIXMAC_THEME, minimap: { enabled: false }, fontSize: 13, scrollBeyondLastLine: false, diff --git a/apps/native/src/components/widget/summaries/collapsible-diff.tsx b/apps/native/src/components/widget/summaries/collapsible-diff.tsx index 2fe55e3ff..ff5e261c0 100644 --- a/apps/native/src/components/widget/summaries/collapsible-diff.tsx +++ b/apps/native/src/components/widget/summaries/collapsible-diff.tsx @@ -59,17 +59,19 @@ export function CollapsibleDiff({
{headerExtra}
)}
- + {change.changeType !== "removed" && ( + + )}
{children}
diff --git a/apps/native/src/components/widget/summaries/diff-editor.tsx b/apps/native/src/components/widget/summaries/diff-editor.tsx new file mode 100644 index 000000000..a411becaa --- /dev/null +++ b/apps/native/src/components/widget/summaries/diff-editor.tsx @@ -0,0 +1,64 @@ +import type { FileDiffContents } from "@/types/shared"; +import { DiffEditor as MonacoDiffEditor } from "@monaco-editor/react"; +import type { editor } from "monaco-editor"; +import { useEffect, useRef } from "react"; +import { DIFF_EDITOR_OPTIONS, monaco } from "./monaco-setup"; + +interface DiffEditorProps { + contents: FileDiffContents; + filename: string; + onMount: (editor: editor.IStandaloneDiffEditor) => void; +} + +export function DiffEditor({ contents, filename, onMount }: DiffEditorProps) { + const disposableRef = useRef(null); + const editorRef = useRef(null); + const lineCount = Math.max( + contents.original.split("\n").length, + contents.modified.split("\n").length, + ); + + useEffect(() => { + return () => { + editorRef.current?.setModel(null); + disposableRef.current?.dispose(); + disposableRef.current = null; + }; + }, []); + + return ( + { + editorRef.current = ed; + onMount(ed); + ed.getOriginalEditor().updateOptions({ lineNumbers: "off" }); + + const decorate = () => { + try { + const diffs = ed.getLineChanges() ?? []; + ed.getModifiedEditor().createDecorationsCollection( + diffs + .filter((d: editor.ILineChange) => d.modifiedEndLineNumber > 0) + .map((d: editor.ILineChange) => ({ + range: new monaco.Range(d.modifiedStartLineNumber, 1, d.modifiedEndLineNumber, 10000), + options: { + inlineClassName: "nixmac-line-added", + linesDecorationsClassName: "nixmac-gutter-added", + }, + })), + ); + } catch { /* editor disposed */ } + }; + + disposableRef.current = ed.onDidUpdateDiff(decorate); + decorate(); + }} + /> + ); +} diff --git a/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx b/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx index 15e65b566..609eb6d98 100644 --- a/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx +++ b/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx @@ -1,21 +1,18 @@ import { getShortFilename, type ChangeWithRichType } from "@/components/widget/utils"; import type { FileDiffContents } from "@/types/shared"; -import { DiffEditor } from "@monaco-editor/react"; import type { editor } from "monaco-editor"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import { CollapsibleDiff } from "./collapsible-diff"; import { HunkPill } from "./hunk-pill"; -import { DIFF_EDITOR_OPTIONS, monaco } from "./monaco-setup"; +import { DiffEditor } from "./diff-editor"; +import { monaco } from "./monaco-setup"; +import { PlainEditor } from "./plain-editor"; function getModStartLine(diff: string): number | null { const match = /@@ -\d+(?:,\d+)? \+(\d+)/.exec(diff); return match ? parseInt(match[1]) : null; } -// ============================================================================= -// FullFileDiffEditor -// ============================================================================= - interface FullFileDiffEditorProps { filename: string; changes: ChangeWithRichType[]; @@ -51,17 +48,14 @@ export function FullFileDiffEditor({ filename, changes, contents, defaultOpen }: } }; - const handleEditorMount = (ed: editor.IStandaloneDiffEditor) => { - editorRef.current = ed; - }; - const displayChange: ChangeWithRichType = { ...changes[0], - changeType: "edited", shortFilename: getShortFilename(filename), - hasMultipleHunks: true, + hasMultipleHunks: changes.length > 1, }; + const changeType = displayChange.changeType; + return ( {contents ? ( - + changeType === "new" || changeType === "removed" ? ( + + ) : ( + { editorRef.current = ed; }} /> + ) ) : (
Loading... @@ -85,63 +83,3 @@ export function FullFileDiffEditor({ filename, changes, contents, defaultOpen }: ); } - -// ============================================================================= -// InlineDiffEditor (internal) -// ============================================================================= - -interface InlineDiffEditorProps { - contents: FileDiffContents; - filename: string; - onMount: (editor: editor.IStandaloneDiffEditor) => void; -} - -function InlineDiffEditor({ contents, filename, onMount }: InlineDiffEditorProps) { - const disposableRef = useRef(null); - const lineCount = Math.max( - contents.original.split("\n").length, - contents.modified.split("\n").length, - ); - - useEffect(() => { - return () => { - disposableRef.current?.dispose(); - disposableRef.current = null; - }; - }, []); - - return ( - { - onMount(ed); - ed.getOriginalEditor().updateOptions({ lineNumbers: "off" }); - - const decorate = () => { - try { - const diffs = ed.getLineChanges() ?? []; - ed.getModifiedEditor().createDecorationsCollection( - diffs - .filter((d) => d.modifiedEndLineNumber > 0) - .map((d) => ({ - range: new monaco.Range(d.modifiedStartLineNumber, 1, d.modifiedEndLineNumber, 10000), - options: { - inlineClassName: "nixmac-line-added", - linesDecorationsClassName: "nixmac-gutter-added", - }, - })), - ); - } catch { /* editor disposed */ } - }; - - disposableRef.current = ed.onDidUpdateDiff(decorate); - decorate(); - }} - /> - ); -} diff --git a/apps/native/src/components/widget/summaries/hunk-pill.tsx b/apps/native/src/components/widget/summaries/hunk-pill.tsx index 9280b379a..e3d532575 100644 --- a/apps/native/src/components/widget/summaries/hunk-pill.tsx +++ b/apps/native/src/components/widget/summaries/hunk-pill.tsx @@ -40,9 +40,11 @@ export function HunkPill({ change, onClick }: HunkPillProps) { } const { added, removed } = countAddedRemoved(change.diff); - + const showCounts = change.changeType === "edited" || change.changeType === "renamed"; const label = summaryTitle - ?? [added && `+${added}`, removed && `-${removed}`].filter(Boolean).join(" "); + ?? (showCounts ? [added && `+${added}`, removed && `-${removed}`].filter(Boolean).join(" ") : null); + + if (!label) return null; return ( = { + nix: "nix", json: "json", yaml: "yaml", yml: "yaml", toml: "toml", + md: "markdown", sh: "shell", ts: "typescript", js: "javascript", + tsx: "typescript", jsx: "javascript", css: "css", html: "html", xml: "xml", +}; + +export function languageFromFilename(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + return EXT_TO_LANGUAGE[ext] ?? "plaintext"; +} + +export const NIXMAC_THEME = "nixmac-dark"; + export { monaco }; diff --git a/apps/native/src/components/widget/summaries/plain-editor.tsx b/apps/native/src/components/widget/summaries/plain-editor.tsx new file mode 100644 index 000000000..9c8e7188c --- /dev/null +++ b/apps/native/src/components/widget/summaries/plain-editor.tsx @@ -0,0 +1,25 @@ +import type { FileDiffContents } from "@/types/shared"; +import { Editor } from "@monaco-editor/react"; +import { languageFromFilename, PLAIN_EDITOR_OPTIONS } from "./monaco-setup"; + +interface PlainEditorProps { + contents: FileDiffContents; + filename: string; + changeType: "new" | "removed"; +} + +export function PlainEditor({ contents, filename, changeType }: PlainEditorProps) { + const value = changeType === "new" ? contents.modified : contents.original; + const lineCount = value.split("\n").length; + + return ( + + ); +} diff --git a/apps/native/src/components/widget/summaries/summary-or-diff.tsx b/apps/native/src/components/widget/summaries/summary-or-diff.tsx index 795b5ea9c..82aca865a 100644 --- a/apps/native/src/components/widget/summaries/summary-or-diff.tsx +++ b/apps/native/src/components/widget/summaries/summary-or-diff.tsx @@ -25,7 +25,6 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { const evolveState = useWidgetStore((s) => s.evolveState); const { summarizeOnFocus } = useSummary(); const [activeTab, setActiveTab] = useState("summary"); - const [diffMounted, setDiffMounted] = useState(false); useEffect(() => { window.addEventListener("focus", summarizeOnFocus); @@ -35,18 +34,6 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { if (!gitStatus || !evolveState || evolveState.step === "begin") { return null; } - const handleTabChange = (tab: string) => { - if (tab !== "diff") { - // Unmount editors first, then switch tab next frame - requestAnimationFrame(() => { - setActiveTab(tab); - if (tab === "summary") summarizeOnFocus(); - }); - } else { - setActiveTab(tab); - if (!diffMounted) setDiffMounted(true); - } - }; const hashSet = new Set(changeMap?.unsummarizedHashes); const unsummarized = (gitStatus?.changes.filter((c) => hashSet.has(c.hash)) || @@ -56,7 +43,7 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { return ( )} - {diffMounted && ( - // Monaco crashes on unmount, so we just hide it -
- -
+ {activeTab === "diff" && ( + )}
diff --git a/apps/native/src/components/widget/utils.ts b/apps/native/src/components/widget/utils.ts index f53ee2fcd..c785656b7 100644 --- a/apps/native/src/components/widget/utils.ts +++ b/apps/native/src/components/widget/utils.ts @@ -215,8 +215,8 @@ export type ChangeFileSummary = ChangeWithRichType & { }; function inferChangeType(diff: string): ChangeType { - if (/^new file mode/m.test(diff)) return "new"; - if (/^deleted file mode/m.test(diff)) return "removed"; + if (/^@@ -0(?:,0)? \+/.test(diff)) return "new"; + if (/^@@ -\d+(?:,\d+)? \+0(?:,0)? @@/.test(diff)) return "removed"; return "edited"; } diff --git a/apps/native/src/components/widget/widget.tsx b/apps/native/src/components/widget/widget.tsx index 27b6a8647..762561032 100644 --- a/apps/native/src/components/widget/widget.tsx +++ b/apps/native/src/components/widget/widget.tsx @@ -23,7 +23,6 @@ import { PermissionsStep, SetupStep, } from "@/components/widget/steps"; -import { useErrorHandler } from "@/hooks/use-error-handler"; import { useGitOperations } from "@/hooks/use-git-operations"; import { useNixInstall } from "@/hooks/use-nix-install"; import { usePanicHandler } from "@/hooks/use-panic-handler"; @@ -63,7 +62,7 @@ export function DarwinWidget() { useTrayEvents(); // Set up error handler to catch unhandled JavaScript errors and promise rejections - useErrorHandler(); + // useErrorHandler(); // disabled: produces "Script error." noise from Monaco workers with no actionable info // Set up test helpers for error handlers and widget store (development only) useEffect(() => { diff --git a/apps/native/src/lib/lsp-client.ts b/apps/native/src/lib/lsp-client.ts index c4bd4c2fd..cded5333d 100644 --- a/apps/native/src/lib/lsp-client.ts +++ b/apps/native/src/lib/lsp-client.ts @@ -30,6 +30,7 @@ export class NixdLspClient { { resolve: (value: unknown) => void; reject: (reason: unknown) => void } >(); private unlisten: UnlistenFn | null = null; + private unlistenExit: UnlistenFn | null = null; private notificationHandlers: NotificationHandler[] = []; private _running = false; @@ -47,6 +48,10 @@ export class NixdLspClient { this.handleMessage(event.payload); }); + this.unlistenExit = await listen("lsp:exit", () => { + this.markDead(); + }); + this._running = true; // Send LSP initialize @@ -89,6 +94,8 @@ export class NixdLspClient { this.unlisten?.(); this.unlisten = null; + this.unlistenExit?.(); + this.unlistenExit = null; this._running = false; this.pending.clear(); } @@ -101,7 +108,11 @@ export class NixdLspClient { this.pending.set(id, { resolve, reject }); }); - await invoke("lsp_send", { message: JSON.stringify(request) }); + await invoke("lsp_send", { message: JSON.stringify(request) }).catch((e) => { + this.pending.delete(id); + if (String(e).includes("Broken pipe")) this.markDead(); + throw e; + }); return promise; } @@ -109,9 +120,19 @@ export class NixdLspClient { const notification: JsonRpcNotification = { jsonrpc: "2.0", method, params }; invoke("lsp_send", { message: JSON.stringify(notification) }).catch((e) => { console.warn("[lsp-client] Failed to send notification:", e); + if (String(e).includes("Broken pipe")) this.markDead(); }); } + private markDead(): void { + this._running = false; + this.unlisten?.(); + this.unlisten = null; + this.unlistenExit?.(); + this.unlistenExit = null; + this.pending.clear(); + } + onNotification(handler: NotificationHandler): () => void { this.notificationHandlers.push(handler); return () => { diff --git a/apps/native/src/lib/nix-grammar.ts b/apps/native/src/lib/nix-grammar.ts index 518f625ae..4b1fa45dc 100644 --- a/apps/native/src/lib/nix-grammar.ts +++ b/apps/native/src/lib/nix-grammar.ts @@ -1,7 +1,7 @@ import type * as monacoNs from "monaco-editor"; import { wireTmGrammars } from "monaco-editor-textmate"; +import { loadWASM } from "onigasm"; import { Registry } from "monaco-textmate"; -import { loadWASM } from "vscode-oniguruma"; let initialized = false; @@ -11,13 +11,8 @@ export async function initNixGrammar( ): Promise { if (initialized) return; - // Load oniguruma WASM - const onigWasmUrl = new URL( - "vscode-oniguruma/release/onig.wasm", - import.meta.url, - ); - const response = await fetch(onigWasmUrl); - await loadWASM(response); + const onigWasmUrl = new URL("onigasm/lib/onigasm.wasm", import.meta.url); + await loadWASM(onigWasmUrl.href); // Register Nix language with Monaco if not already registered const langs = monaco.languages.getLanguages(); diff --git a/bun.lock b/bun.lock index b27d0a723..566069b59 100644 --- a/bun.lock +++ b/bun.lock @@ -49,6 +49,7 @@ "monaco-editor-textmate": "^4.0.0", "monaco-textmate": "^3.0.1", "motion": "^12.35.2", + "onigasm": "^2.2.5", "react": "^19.2.3", "react-dom": "^19.1.0", "react-icons": "^5.5.0", @@ -1506,7 +1507,7 @@ "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], @@ -2176,8 +2177,6 @@ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], @@ -2358,8 +2357,6 @@ "nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], - "onigasm/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], @@ -2390,6 +2387,8 @@ "proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], "read-pkg/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], From 099a0efde657bbb5c23d8e0a2840d35604c99d6c Mon Sep 17 00:00:00 2001 From: CasLinden Date: Tue, 5 May 2026 16:12:08 +0900 Subject: [PATCH 03/15] chore(widget): remove problematic error handling hook --- apps/native/src/components/widget/widget.tsx | 4 - apps/native/src/hooks/use-error-handler.ts | 119 ------------------- 2 files changed, 123 deletions(-) delete mode 100644 apps/native/src/hooks/use-error-handler.ts diff --git a/apps/native/src/components/widget/widget.tsx b/apps/native/src/components/widget/widget.tsx index 27b6a8647..1fd0b16ac 100644 --- a/apps/native/src/components/widget/widget.tsx +++ b/apps/native/src/components/widget/widget.tsx @@ -23,7 +23,6 @@ import { PermissionsStep, SetupStep, } from "@/components/widget/steps"; -import { useErrorHandler } from "@/hooks/use-error-handler"; import { useGitOperations } from "@/hooks/use-git-operations"; import { useNixInstall } from "@/hooks/use-nix-install"; import { usePanicHandler } from "@/hooks/use-panic-handler"; @@ -62,9 +61,6 @@ export function DarwinWidget() { // Listen for tray menu events (Send Feedback, Settings) useTrayEvents(); - // Set up error handler to catch unhandled JavaScript errors and promise rejections - useErrorHandler(); - // Set up test helpers for error handlers and widget store (development only) useEffect(() => { if (import.meta.env.DEV) { diff --git a/apps/native/src/hooks/use-error-handler.ts b/apps/native/src/hooks/use-error-handler.ts deleted file mode 100644 index 65ca0b3d5..000000000 --- a/apps/native/src/hooks/use-error-handler.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Hook to capture and handle unhandled JavaScript errors and promise rejections - */ -import { useEffect } from "react"; -import { toast } from "sonner"; -import { useWidgetStore } from "@/stores/widget-store"; -import { FeedbackType } from "@/types/feedback"; - -interface JavaScriptErrorDetails { - message: string; - stack?: string; - filename?: string; - lineno?: number; - colno?: number; - timestamp: string; - type: "error" | "unhandledrejection"; -} - -export function useErrorHandler() { - const { setError, openFeedback, setPanicDetails } = useWidgetStore(); - - useEffect(() => { - // Handle uncaught errors - const handleError = (event: ErrorEvent) => { - console.error("Unhandled JavaScript error:", event); - - const errorDetails: JavaScriptErrorDetails = { - message: event.message, - stack: event.error?.stack, - filename: event.filename, - lineno: event.lineno, - colno: event.colno, - timestamp: new Date().toISOString(), - type: "error", - }; - - // Format error message for display - const errorMessage = `Unhandled Error: ${event.message}${ - event.filename ? `\n\nFile: ${event.filename}:${event.lineno}:${event.colno}` : "" - }${ - event.error?.stack - ? `\n\nStack trace available - see console or submit feedback for details` - : "" - }`; - - // Set the error state - setError(errorMessage); - - // Store error details as panic details (same structure as Rust panics for simplicity) - setPanicDetails({ - message: event.message, - location: event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : undefined, - backtrace: event.error?.stack, - timestamp: errorDetails.timestamp, - }); - - // Show toast notification in case we obscure the main window error message with the feedback dialog. - toast.error("JavaScript Error Detected", { - description: "The application encountered an unexpected error. Please report this issue.", - duration: 10000, - }); - - openFeedback(FeedbackType.Error, errorMessage); - - event.preventDefault(); - }; - - // Handle unhandled promise rejections - const handleRejection = (event: PromiseRejectionEvent) => { - console.error("Unhandled promise rejection:", event); - - const reason = event.reason; - const message = reason instanceof Error ? reason.message : String(reason); - const stack = reason instanceof Error ? reason.stack : undefined; - - const errorDetails: JavaScriptErrorDetails = { - message, - stack, - timestamp: new Date().toISOString(), - type: "unhandledrejection", - }; - - // Format error message for display - const errorMessage = `Unhandled Promise Rejection: ${message}${ - stack ? `\n\nStack trace available - see console or submit feedback for details` : "" - }`; - - // Set the error state - setError(errorMessage); - - // Store error details - setPanicDetails({ - message, - backtrace: stack, - timestamp: errorDetails.timestamp, - }); - - // Show toast notification - toast.error("Async Operation Failed", { - description: "An asynchronous operation failed unexpectedly. Please report this issue.", - duration: 10000, - }); - - openFeedback(FeedbackType.Error, errorMessage); - - event.preventDefault(); - }; - - // Register event listeners - window.addEventListener("error", handleError); - window.addEventListener("unhandledrejection", handleRejection); - - // Cleanup on unmount - return () => { - window.removeEventListener("error", handleError); - window.removeEventListener("unhandledrejection", handleRejection); - }; - }, [setError, openFeedback, setPanicDetails]); -} From f41f741b325a707c8fce42f24618a755f5887fbf Mon Sep 17 00:00:00 2001 From: CasLinden Date: Tue, 5 May 2026 16:57:30 +0900 Subject: [PATCH 04/15] chore(widget): update editor stories, add mocks and improve testability --- apps/native/.storybook/vitest.setup.ts | 7 ++- .../permissions-screen.test.tsx.snap | 14 ++++- .../__snapshots__/nix-editor.stories.tsx.snap | 6 +- .../nix-editor/use-nix-editor.test.ts | 1 + .../components/nix-editor/use-nix-editor.ts | 4 +- .../collapsible-diff.stories.tsx.snap | 2 +- .../diff-section.stories.tsx.snap | 4 ++ .../full-file-diff-editor.stories.tsx.snap | 2 +- .../widget/summaries/diff-section.tsx | 2 +- .../widget/summaries/monaco-setup.ts | 57 +------------------ .../widget/summaries/monaco-theme.ts | 55 ++++++++++++++++++ .../src/components/widget/widget.test.tsx | 8 +++ 12 files changed, 98 insertions(+), 64 deletions(-) create mode 100644 apps/native/src/components/widget/summaries/monaco-theme.ts diff --git a/apps/native/.storybook/vitest.setup.ts b/apps/native/.storybook/vitest.setup.ts index f6e72d9b6..f19043092 100644 --- a/apps/native/.storybook/vitest.setup.ts +++ b/apps/native/.storybook/vitest.setup.ts @@ -36,7 +36,12 @@ function normalizeSnapshotRoot(root: Element): string { editor.appendChild(placeholder); } - return normalizeAnimations(clone.innerHTML); + let html = normalizeAnimations(clone.innerHTML); + // Monaco assigns auto-incrementing model IDs that vary by test-suite order. + html = html.replace(/inmemory:\/\/model\/\d+/g, "inmemory://model/N"); + // data-keybinding-context values are similarly auto-incremented. + html = html.replace(/data-keybinding-context="\d+"/g, 'data-keybinding-context="N"'); + return html; } // Automatically snapshot every story after it renders diff --git a/apps/native/src/components/__snapshots__/permissions-screen.test.tsx.snap b/apps/native/src/components/__snapshots__/permissions-screen.test.tsx.snap index a6924a364..3b1b8e3e0 100644 --- a/apps/native/src/components/__snapshots__/permissions-screen.test.tsx.snap +++ b/apps/native/src/components/__snapshots__/permissions-screen.test.tsx.snap @@ -228,6 +228,11 @@ exports[`all granted matches snapshot 1`] = ` > Full Disk Access + + Required + @@ -239,7 +244,7 @@ exports[`all granted matches snapshot 1`] = `

- Recommended for complete system management capabilities + Required for darwin-rebuild to apply system changes

Full Disk Access + + Required + @@ -566,7 +576,7 @@ exports[`default state matches snapshot 1`] = `

- Recommended for complete system management capabilities + Required for darwin-rebuild to apply system changes

"`; +exports[`Configuration Nix 1`] = `"
"`; -exports[`Flake Nix 1`] = `"
"`; +exports[`Flake Nix 1`] = `"
"`; -exports[`Unknown File 1`] = `"
"`; +exports[`Unknown File 1`] = `"
"`; diff --git a/apps/native/src/components/nix-editor/use-nix-editor.test.ts b/apps/native/src/components/nix-editor/use-nix-editor.test.ts index 29334b9d0..db6d9aef1 100644 --- a/apps/native/src/components/nix-editor/use-nix-editor.test.ts +++ b/apps/native/src/components/nix-editor/use-nix-editor.test.ts @@ -93,6 +93,7 @@ vi.mock("monaco-editor", () => ({ editor: { create: (container: HTMLElement, options: { value: string }) => h.monacoCreate(container, options), + defineTheme: vi.fn(), setModelMarkers: vi.fn(), }, languages: { diff --git a/apps/native/src/components/nix-editor/use-nix-editor.ts b/apps/native/src/components/nix-editor/use-nix-editor.ts index a763c6d32..9d85ad652 100644 --- a/apps/native/src/components/nix-editor/use-nix-editor.ts +++ b/apps/native/src/components/nix-editor/use-nix-editor.ts @@ -4,7 +4,7 @@ import { initNixGrammar } from "@/lib/nix-grammar"; import { lspClient } from "@/lib/lsp-client"; import { bridgeMonacoToLsp } from "@/lib/lsp-monaco-bridge"; import { darwinAPI } from "@/tauri-api"; -import { NIXMAC_THEME } from "@/components/widget/summaries/monaco-setup"; +import { NIXMAC_THEME, NIXMAC_THEME_DATA } from "@/components/widget/summaries/monaco-theme"; interface UseNixEditorOptions { filePath: string; @@ -71,6 +71,8 @@ export function useNixEditor({ filePath, containerRef, onSave, disabled = false if (disposed) return; + monaco.editor.defineTheme(NIXMAC_THEME, NIXMAC_THEME_DATA); + // Create editor editor = monaco.editor.create(container!, { value: content, diff --git a/apps/native/src/components/widget/summaries/__snapshots__/collapsible-diff.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/collapsible-diff.stories.tsx.snap index e2d0abf50..e496de992 100644 --- a/apps/native/src/components/widget/summaries/__snapshots__/collapsible-diff.stories.tsx.snap +++ b/apps/native/src/components/widget/summaries/__snapshots__/collapsible-diff.stories.tsx.snap @@ -4,7 +4,7 @@ exports[`Collapsed Edited 1`] = `"
modules/darwin/fonts.nix
Diff content here
"`; -exports[`Removed 1`] = `"
modules/home/old-shell.nix
"`; +exports[`Removed 1`] = `"
modules/home/old-shell.nix
"`; exports[`Renamed 1`] = `"
modules/darwin/brew.nix
"`; diff --git a/apps/native/src/components/widget/summaries/__snapshots__/diff-section.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/diff-section.stories.tsx.snap index 5cec26eda..6d1258b1b 100644 --- a/apps/native/src/components/widget/summaries/__snapshots__/diff-section.stories.tsx.snap +++ b/apps/native/src/components/widget/summaries/__snapshots__/diff-section.stories.tsx.snap @@ -1,3 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`Empty 1`] = `"
No diff available
"`; + +exports[`Multiple Files 1`] = `"
modules/darwin/packages.nix
+2
Loading...
modules/home/shell.nix
+5
flake.nix
+1
"`; + +exports[`Single File 1`] = `"
modules/darwin/packages.nix
+2
Loading...
"`; diff --git a/apps/native/src/components/widget/summaries/__snapshots__/full-file-diff-editor.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/full-file-diff-editor.stories.tsx.snap index c84b1dd24..684514317 100644 --- a/apps/native/src/components/widget/summaries/__snapshots__/full-file-diff-editor.stories.tsx.snap +++ b/apps/native/src/components/widget/summaries/__snapshots__/full-file-diff-editor.stories.tsx.snap @@ -4,6 +4,6 @@ exports[`Collapsed 1`] = `"
configuration.nix
Add ripgrep, fd, jq
Loading...
"`; -exports[`Multiple Hunks 1`] = `"
configuration.nix
Add ripgrep, fd, jqEnable flakes
"`; +exports[`Multiple Hunks 1`] = `"
configuration.nix
Add ripgrep, fd, jqEnable flakes
"`; exports[`Single Hunk 1`] = `"
"`; diff --git a/apps/native/src/components/widget/summaries/diff-section.tsx b/apps/native/src/components/widget/summaries/diff-section.tsx index f2ac10267..d0e1f367e 100644 --- a/apps/native/src/components/widget/summaries/diff-section.tsx +++ b/apps/native/src/components/widget/summaries/diff-section.tsx @@ -38,7 +38,7 @@ export function DiffSection({ changes }: DiffSectionProps) { } darwinAPI.git .fileDiffContents(filenames) - .then(setFileContents) + .then((result) => setFileContents(result ?? {})) .catch(() => setFileContents({})); }, [filenamesKey]); diff --git a/apps/native/src/components/widget/summaries/monaco-setup.ts b/apps/native/src/components/widget/summaries/monaco-setup.ts index f9605ef47..2348d3405 100644 --- a/apps/native/src/components/widget/summaries/monaco-setup.ts +++ b/apps/native/src/components/widget/summaries/monaco-setup.ts @@ -1,63 +1,12 @@ import { loader } from "@monaco-editor/react"; import * as monaco from "monaco-editor"; import type { editor } from "monaco-editor"; +import { NIXMAC_THEME, NIXMAC_THEME_DATA } from "./monaco-theme"; // Use the locally bundled monaco-editor instead of CDN (required in Tauri offline context) loader.config({ monaco }); -monaco.editor.defineTheme("nixmac-dark", { - base: "vs-dark", - inherit: true, - rules: [ - { token: "", foreground: "fafafa" }, - { token: "comment", foreground: "a3a3a3", fontStyle: "italic" }, - { token: "string", foreground: "23d0e7" }, - { token: "string.escape", foreground: "f7b23b" }, - { token: "keyword", foreground: "55a0f6" }, - { token: "keyword.control", foreground: "55a0f6" }, - { token: "keyword.operator", foreground: "a3a3a3" }, - { token: "constant", foreground: "f7b23b" }, - { token: "constant.language", foreground: "f7b23b" }, - { token: "constant.numeric", foreground: "f7b23b" }, - { token: "number", foreground: "f7b23b" }, - { token: "type", foreground: "55a0f6" }, - { token: "type.identifier", foreground: "55a0f6" }, - { token: "entity.name.function", foreground: "23d0e7" }, - { token: "entity.name.type", foreground: "55a0f6" }, - { token: "entity.name.tag", foreground: "f4587c" }, - { token: "support.function", foreground: "23d0e7" }, - { token: "variable.parameter", foreground: "f7b23b" }, - { token: "invalid", foreground: "f4587c" }, - { token: "addition.diff", foreground: "23d0e7" }, - { token: "deletion.diff", foreground: "f4587c" }, - { token: "info.diff", foreground: "a3a3a3" }, - ], - colors: { - "editor.background": "#0a0a0a", - "editor.foreground": "#fafafa", - "editorLineNumber.foreground": "#404040", - "editorLineNumber.activeForeground": "#a3a3a3", - "editor.selectionBackground": "#23d0e730", - "editor.inactiveSelectionBackground": "#23d0e718", - "editor.lineHighlightBackground": "#141414", - "editorCursor.foreground": "#23d0e7", - "editorGutter.background": "#0a0a0a", - "editorGutter.addedBackground": "#23d0e740", - "editorGutter.deletedBackground": "#f4587c40", - "editorGutter.modifiedBackground": "#f7b23b40", - "scrollbarSlider.background": "#26262680", - "scrollbarSlider.hoverBackground": "#404040aa", - "scrollbarSlider.activeBackground": "#555555aa", - "editorWidget.background": "#141414", - "editorWidget.border": "#262626", - "diffEditor.insertedLineBackground": "#23d0e715", - "diffEditor.removedLineBackground": "#f4587c15", - "diffEditor.insertedTextBackground": "#00000000", - "diffEditor.removedTextBackground": "#00000000", - "diffEditorGutter.insertedLineBackground": "#00000000", - "diffEditorGutter.removedLineBackground": "#00000000", - }, -}); +monaco.editor.defineTheme(NIXMAC_THEME, NIXMAC_THEME_DATA); export const PLAIN_EDITOR_OPTIONS: editor.IStandaloneEditorConstructionOptions = { readOnly: true, @@ -126,6 +75,6 @@ export function languageFromFilename(filename: string): string { return EXT_TO_LANGUAGE[ext] ?? "plaintext"; } -export const NIXMAC_THEME = "nixmac-dark"; +export { NIXMAC_THEME, NIXMAC_THEME_DATA } from "./monaco-theme"; export { monaco }; diff --git a/apps/native/src/components/widget/summaries/monaco-theme.ts b/apps/native/src/components/widget/summaries/monaco-theme.ts new file mode 100644 index 000000000..11564c1cc --- /dev/null +++ b/apps/native/src/components/widget/summaries/monaco-theme.ts @@ -0,0 +1,55 @@ +export const NIXMAC_THEME = "nixmac-dark"; + +export const NIXMAC_THEME_DATA = { + base: "vs-dark" as const, + inherit: true, + rules: [ + { token: "", foreground: "fafafa" }, + { token: "comment", foreground: "a3a3a3", fontStyle: "italic" }, + { token: "string", foreground: "23d0e7" }, + { token: "string.escape", foreground: "f7b23b" }, + { token: "keyword", foreground: "55a0f6" }, + { token: "keyword.control", foreground: "55a0f6" }, + { token: "keyword.operator", foreground: "a3a3a3" }, + { token: "constant", foreground: "f7b23b" }, + { token: "constant.language", foreground: "f7b23b" }, + { token: "constant.numeric", foreground: "f7b23b" }, + { token: "number", foreground: "f7b23b" }, + { token: "type", foreground: "55a0f6" }, + { token: "type.identifier", foreground: "55a0f6" }, + { token: "entity.name.function", foreground: "23d0e7" }, + { token: "entity.name.type", foreground: "55a0f6" }, + { token: "entity.name.tag", foreground: "f4587c" }, + { token: "support.function", foreground: "23d0e7" }, + { token: "variable.parameter", foreground: "f7b23b" }, + { token: "invalid", foreground: "f4587c" }, + { token: "addition.diff", foreground: "23d0e7" }, + { token: "deletion.diff", foreground: "f4587c" }, + { token: "info.diff", foreground: "a3a3a3" }, + ], + colors: { + "editor.background": "#0a0a0a", + "editor.foreground": "#fafafa", + "editorLineNumber.foreground": "#404040", + "editorLineNumber.activeForeground": "#a3a3a3", + "editor.selectionBackground": "#23d0e730", + "editor.inactiveSelectionBackground": "#23d0e718", + "editor.lineHighlightBackground": "#141414", + "editorCursor.foreground": "#23d0e7", + "editorGutter.background": "#0a0a0a", + "editorGutter.addedBackground": "#23d0e740", + "editorGutter.deletedBackground": "#f4587c40", + "editorGutter.modifiedBackground": "#f7b23b40", + "scrollbarSlider.background": "#26262680", + "scrollbarSlider.hoverBackground": "#404040aa", + "scrollbarSlider.activeBackground": "#555555aa", + "editorWidget.background": "#141414", + "editorWidget.border": "#262626", + "diffEditor.insertedLineBackground": "#23d0e715", + "diffEditor.removedLineBackground": "#f4587c15", + "diffEditor.insertedTextBackground": "#00000000", + "diffEditor.removedTextBackground": "#00000000", + "diffEditorGutter.insertedLineBackground": "#00000000", + "diffEditorGutter.removedLineBackground": "#00000000", + }, +}; diff --git a/apps/native/src/components/widget/widget.test.tsx b/apps/native/src/components/widget/widget.test.tsx index 9ad231462..9a3616da6 100644 --- a/apps/native/src/components/widget/widget.test.tsx +++ b/apps/native/src/components/widget/widget.test.tsx @@ -21,6 +21,14 @@ vi.mock("@/tauri-api", () => ({ DEFAULT_MAX_ITERATIONS: 25, })); +vi.mock("@/components/editor-panel", () => ({ + EditorPanel: () => null, +})); + +vi.mock("@/components/widget/summaries/diff-section", () => ({ + DiffSection: () => null, +})); + // Mock hooks vi.mock("@/hooks/use-widget-initialization", () => ({ loadConfig: vi.fn().mockResolvedValue(undefined), From 4409723be561f27162c8bd3e43707d9a09a44789 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Tue, 5 May 2026 17:14:44 +0900 Subject: [PATCH 05/15] chore(widget): remove unused util and add monaco dep in native --- apps/native/package.json | 1 + apps/native/src/components/widget/utils.ts | 17 ----------------- bun.lock | 7 +++++++ bun.nix | 12 ++++++++++++ 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/native/package.json b/apps/native/package.json index 4fc377530..ca2be8370 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -64,6 +64,7 @@ "cmdk": "^1.1.1", "execa": "^9.6.0", "lucide-react": "^0.562.0", + "@monaco-editor/react": "^4.7.0", "monaco-editor": "^0.55.1", "monaco-editor-textmate": "^4.0.0", "monaco-textmate": "^3.0.1", diff --git a/apps/native/src/components/widget/utils.ts b/apps/native/src/components/widget/utils.ts index c785656b7..dbb217e9b 100644 --- a/apps/native/src/components/widget/utils.ts +++ b/apps/native/src/components/widget/utils.ts @@ -44,23 +44,6 @@ export function getDirectory(path: string): string { return parts.slice(0, -1).join("/"); } -/** - * Infer change type from diff chunk content. - */ -function getChangeTypeFromChunks( - chunks: string, -): "new" | "edited" | "removed" { - const contentLines = chunks - .split("\n") - .filter((l) => l.startsWith("+") || l.startsWith("-")); - if (contentLines.length === 0) return "edited"; - const hasAdditions = contentLines.some((l) => l.startsWith("+")); - const hasDeletions = contentLines.some((l) => l.startsWith("-")); - if (hasAdditions && !hasDeletions) return "new"; - if (!hasAdditions && hasDeletions) return "removed"; - return "edited"; -} - // ============================================================================= // SUMMARY CATEGORY COLORS diff --git a/bun.lock b/bun.lock index 566069b59..35e8fd62e 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "name": "native", "version": "0.22.0", "dependencies": { + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -309,6 +310,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], + + "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], @@ -1927,6 +1932,8 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], diff --git a/bun.nix b/bun.nix index d10c41de3..65743d9db 100644 --- a/bun.nix +++ b/bun.nix @@ -433,6 +433,14 @@ url = "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz"; hash = "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="; }; + "@monaco-editor/loader@1.7.0" = fetchurl { + url = "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz"; + hash = "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="; + }; + "@monaco-editor/react@4.7.0" = fetchurl { + url = "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz"; + hash = "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="; + }; "@napi-rs/wasm-runtime@1.1.4" = fetchurl { url = "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz"; hash = "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="; @@ -4094,6 +4102,10 @@ url = "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz"; hash = "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="; }; + "state-local@1.0.7" = fetchurl { + url = "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz"; + hash = "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="; + }; "std-env@3.10.0" = fetchurl { url = "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz"; hash = "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="; From 1dbbea85a7005d270ea071f277156c8c1e1c7f91 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Fri, 8 May 2026 19:58:28 +0900 Subject: [PATCH 06/15] chore(monaco-editors): rename components to avoid confusion --- .../summaries/{diff-editor.tsx => diff-view.tsx} | 13 +++++++------ .../summaries/{plain-editor.tsx => file-view.tsx} | 11 ++++++----- .../widget/summaries/full-file-diff-editor.tsx | 8 ++++---- .../src/components/widget/summaries/monaco-setup.ts | 2 +- 4 files changed, 18 insertions(+), 16 deletions(-) rename apps/native/src/components/widget/summaries/{diff-editor.tsx => diff-view.tsx} (83%) rename apps/native/src/components/widget/summaries/{plain-editor.tsx => file-view.tsx} (59%) diff --git a/apps/native/src/components/widget/summaries/diff-editor.tsx b/apps/native/src/components/widget/summaries/diff-view.tsx similarity index 83% rename from apps/native/src/components/widget/summaries/diff-editor.tsx rename to apps/native/src/components/widget/summaries/diff-view.tsx index a411becaa..bf54ec8b5 100644 --- a/apps/native/src/components/widget/summaries/diff-editor.tsx +++ b/apps/native/src/components/widget/summaries/diff-view.tsx @@ -1,16 +1,16 @@ import type { FileDiffContents } from "@/types/shared"; -import { DiffEditor as MonacoDiffEditor } from "@monaco-editor/react"; +import { DiffEditor } from "@monaco-editor/react"; import type { editor } from "monaco-editor"; import { useEffect, useRef } from "react"; -import { DIFF_EDITOR_OPTIONS, monaco } from "./monaco-setup"; +import { DIFF_EDITOR_OPTIONS, monaco, NIXMAC_THEME, NIXMAC_THEME_DATA } from "./monaco-setup"; -interface DiffEditorProps { +interface DiffViewProps { contents: FileDiffContents; filename: string; onMount: (editor: editor.IStandaloneDiffEditor) => void; } -export function DiffEditor({ contents, filename, onMount }: DiffEditorProps) { +export function DiffView({ contents, filename, onMount }: DiffViewProps) { const disposableRef = useRef(null); const editorRef = useRef(null); const lineCount = Math.max( @@ -27,13 +27,14 @@ export function DiffEditor({ contents, filename, onMount }: DiffEditorProps) { }, []); return ( - m.editor.defineTheme(NIXMAC_THEME, NIXMAC_THEME_DATA)} onMount={(ed: editor.IStandaloneDiffEditor) => { editorRef.current = ed; onMount(ed); diff --git a/apps/native/src/components/widget/summaries/plain-editor.tsx b/apps/native/src/components/widget/summaries/file-view.tsx similarity index 59% rename from apps/native/src/components/widget/summaries/plain-editor.tsx rename to apps/native/src/components/widget/summaries/file-view.tsx index 9c8e7188c..4f07cf36d 100644 --- a/apps/native/src/components/widget/summaries/plain-editor.tsx +++ b/apps/native/src/components/widget/summaries/file-view.tsx @@ -1,14 +1,14 @@ import type { FileDiffContents } from "@/types/shared"; import { Editor } from "@monaco-editor/react"; -import { languageFromFilename, PLAIN_EDITOR_OPTIONS } from "./monaco-setup"; +import { languageFromFilename, NIXMAC_THEME, NIXMAC_THEME_DATA, FILE_VIEW_OPTIONS } from "./monaco-setup"; -interface PlainEditorProps { +interface FileViewProps { contents: FileDiffContents; filename: string; changeType: "new" | "removed"; } -export function PlainEditor({ contents, filename, changeType }: PlainEditorProps) { +export function FileView({ contents, filename, changeType }: FileViewProps) { const value = changeType === "new" ? contents.modified : contents.original; const lineCount = value.split("\n").length; @@ -18,8 +18,9 @@ export function PlainEditor({ contents, filename, changeType }: PlainEditorProps height={Math.min(Math.max(lineCount * 19, 100), 400)} defaultLanguage={languageFromFilename(filename)} value={value} - theme="nixmac-dark" - options={PLAIN_EDITOR_OPTIONS} + theme={NIXMAC_THEME} + options={FILE_VIEW_OPTIONS} + beforeMount={(m) => m.editor.defineTheme(NIXMAC_THEME, NIXMAC_THEME_DATA)} /> ); } diff --git a/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx b/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx index 609eb6d98..050ac56d7 100644 --- a/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx +++ b/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx @@ -4,9 +4,9 @@ import type { editor } from "monaco-editor"; import { useRef, useState } from "react"; import { CollapsibleDiff } from "./collapsible-diff"; import { HunkPill } from "./hunk-pill"; -import { DiffEditor } from "./diff-editor"; +import { DiffView } from "./diff-view"; import { monaco } from "./monaco-setup"; -import { PlainEditor } from "./plain-editor"; +import { FileView } from "./file-view"; function getModStartLine(diff: string): number | null { const match = /@@ -\d+(?:,\d+)? \+(\d+)/.exec(diff); @@ -71,9 +71,9 @@ export function FullFileDiffEditor({ filename, changes, contents, defaultOpen }: > {contents ? ( changeType === "new" || changeType === "removed" ? ( - + ) : ( - { editorRef.current = ed; }} /> + { editorRef.current = ed; }} /> ) ) : (
diff --git a/apps/native/src/components/widget/summaries/monaco-setup.ts b/apps/native/src/components/widget/summaries/monaco-setup.ts index 2348d3405..3694213ea 100644 --- a/apps/native/src/components/widget/summaries/monaco-setup.ts +++ b/apps/native/src/components/widget/summaries/monaco-setup.ts @@ -8,7 +8,7 @@ loader.config({ monaco }); monaco.editor.defineTheme(NIXMAC_THEME, NIXMAC_THEME_DATA); -export const PLAIN_EDITOR_OPTIONS: editor.IStandaloneEditorConstructionOptions = { +export const FILE_VIEW_OPTIONS: editor.IStandaloneEditorConstructionOptions = { readOnly: true, minimap: { enabled: false }, renderLineHighlight: "none", From 54bd08d9f118aecee5e64a0babd66528fd2b0ed4 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Fri, 8 May 2026 20:01:29 +0900 Subject: [PATCH 07/15] fix(monaco-editor): eagerly initiate nix grammar and add file specicic coloring --- .../widget/summaries/monaco-setup.ts | 5 ++++ .../widget/summaries/monaco-theme.ts | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/apps/native/src/components/widget/summaries/monaco-setup.ts b/apps/native/src/components/widget/summaries/monaco-setup.ts index 3694213ea..a12569f7c 100644 --- a/apps/native/src/components/widget/summaries/monaco-setup.ts +++ b/apps/native/src/components/widget/summaries/monaco-setup.ts @@ -1,6 +1,7 @@ import { loader } from "@monaco-editor/react"; import * as monaco from "monaco-editor"; import type { editor } from "monaco-editor"; +import { initNixGrammar } from "@/lib/nix-grammar"; import { NIXMAC_THEME, NIXMAC_THEME_DATA } from "./monaco-theme"; // Use the locally bundled monaco-editor instead of CDN (required in Tauri offline context) @@ -8,6 +9,10 @@ loader.config({ monaco }); monaco.editor.defineTheme(NIXMAC_THEME, NIXMAC_THEME_DATA); +// Eagerly register the Nix textmate grammar so FileView/DiffView pick up Nix +// highlighting without waiting for the nix-editor panel to open. +initNixGrammar(monaco).catch((e) => console.warn("Nix grammar init failed:", e)); + export const FILE_VIEW_OPTIONS: editor.IStandaloneEditorConstructionOptions = { readOnly: true, minimap: { enabled: false }, diff --git a/apps/native/src/components/widget/summaries/monaco-theme.ts b/apps/native/src/components/widget/summaries/monaco-theme.ts index 11564c1cc..2360031ff 100644 --- a/apps/native/src/components/widget/summaries/monaco-theme.ts +++ b/apps/native/src/components/widget/summaries/monaco-theme.ts @@ -26,6 +26,31 @@ export const NIXMAC_THEME_DATA = { { token: "addition.diff", foreground: "23d0e7" }, { token: "deletion.diff", foreground: "f4587c" }, { token: "info.diff", foreground: "a3a3a3" }, + // JSON-specific (vs-dark has more specific rules that override our generic "string"/"keyword"; explicit suffix wins) + { token: "string.key.json", foreground: "55a0f6" }, + { token: "string.value.json", foreground: "23d0e7" }, + { token: "keyword.json", foreground: "f7b23b" }, + { token: "number.json", foreground: "f7b23b" }, + // YAML-specific + { token: "string.yaml", foreground: "23d0e7" }, + { token: "comment.yaml", foreground: "a3a3a3", fontStyle: "italic" }, + { token: "keyword.yaml", foreground: "55a0f6" }, + { token: "number.yaml", foreground: "f7b23b" }, + { token: "type.yaml", foreground: "55a0f6" }, + { token: "tag.yaml", foreground: "f4587c" }, + // TOML-specific + { token: "string.toml", foreground: "23d0e7" }, + { token: "comment.toml", foreground: "a3a3a3", fontStyle: "italic" }, + { token: "keyword.toml", foreground: "55a0f6" }, + { token: "number.toml", foreground: "f7b23b" }, + { token: "type.toml", foreground: "55a0f6" }, + // Shell-specific + { token: "string.shell", foreground: "23d0e7" }, + { token: "comment.shell", foreground: "a3a3a3", fontStyle: "italic" }, + { token: "keyword.shell", foreground: "55a0f6" }, + { token: "number.shell", foreground: "f7b23b" }, + { token: "variable.shell", foreground: "f7b23b" }, + { token: "predefined.shell", foreground: "23d0e7" }, ], colors: { "editor.background": "#0a0a0a", From 13cf5341df3249789ecdf6ec8eec22fcc0e94653 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Fri, 8 May 2026 20:51:58 +0900 Subject: [PATCH 08/15] chore(diff-editors): prefetch diff data, smoothen UX --- .../widget/summaries/collapsible-diff.tsx | 2 +- .../widget/summaries/diff-section.stories.tsx | 12 +++++-- .../widget/summaries/diff-section.tsx | 32 +++++++------------ .../full-file-diff-editor.stories.tsx | 26 ++++++++++----- .../summaries/full-file-diff-editor.tsx | 14 ++++---- .../widget/summaries/summary-or-diff.tsx | 7 +++- apps/native/src/hooks/use-git-operations.ts | 21 ++++++++++++ apps/native/src/stores/widget-store.impl.ts | 5 +++ 8 files changed, 79 insertions(+), 40 deletions(-) diff --git a/apps/native/src/components/widget/summaries/collapsible-diff.tsx b/apps/native/src/components/widget/summaries/collapsible-diff.tsx index ff5e261c0..e5bb3dd77 100644 --- a/apps/native/src/components/widget/summaries/collapsible-diff.tsx +++ b/apps/native/src/components/widget/summaries/collapsible-diff.tsx @@ -73,7 +73,7 @@ export function CollapsibleDiff({ )}
- +
{children}
diff --git a/apps/native/src/components/widget/summaries/diff-section.stories.tsx b/apps/native/src/components/widget/summaries/diff-section.stories.tsx index e6125a98d..ce62e69b5 100644 --- a/apps/native/src/components/widget/summaries/diff-section.stories.tsx +++ b/apps/native/src/components/widget/summaries/diff-section.stories.tsx @@ -1,8 +1,14 @@ // @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) import preview from "#storybook/preview"; import type { Change } from "@/types/shared"; +import { useState } from "react"; import { DiffSection } from "./diff-section"; +function ControlledDiffSection({ changes }: { changes: Change[] }) { + const [openFiles, setOpenFiles] = useState>({}); + return ; +} + const meta = preview.meta({ title: "Widget/Summaries/DiffSection", component: DiffSection, @@ -66,7 +72,7 @@ const flakeDiff = `diff --git a/flake.nix b/flake.nix export const SingleFile = meta.story({ render: () => (
- +
), }); @@ -74,7 +80,7 @@ export const SingleFile = meta.story({ export const MultipleFiles = meta.story({ render: () => (
- (
- +
), }); diff --git a/apps/native/src/components/widget/summaries/diff-section.tsx b/apps/native/src/components/widget/summaries/diff-section.tsx index d0e1f367e..5b3f416c3 100644 --- a/apps/native/src/components/widget/summaries/diff-section.tsx +++ b/apps/native/src/components/widget/summaries/diff-section.tsx @@ -5,17 +5,19 @@ import { enrichChanges, type ChangeWithRichType, } from "@/components/widget/utils"; -import { darwinAPI } from "@/tauri-api"; -import type { Change, FileDiffContents } from "@/types/shared"; -import { useEffect, useMemo, useState } from "react"; +import { useWidgetStore } from "@/stores/widget-store"; +import type { Change } from "@/types/shared"; +import { useMemo } from "react"; import { FullFileDiffEditor } from "./full-file-diff-editor"; interface DiffSectionProps { changes: Change[]; + openFiles: Record; + onOpenFilesChange: (next: Record) => void; } -export function DiffSection({ changes }: DiffSectionProps) { - const [fileContents, setFileContents] = useState>({}); +export function DiffSection({ changes, openFiles, onOpenFilesChange }: DiffSectionProps) { + const fileContents = useWidgetStore((s) => s.fileDiffContents); const enriched = useMemo(() => enrichChanges(changes), [changes]); @@ -29,19 +31,6 @@ export function DiffSection({ changes }: DiffSectionProps) { return map; }, [enriched]); - const filenames = useMemo(() => [...byFile.keys()], [byFile]); - const filenamesKey = filenames.join(","); - useEffect(() => { - if (filenames.length === 0) { - setFileContents({}); - return; - } - darwinAPI.git - .fileDiffContents(filenames) - .then((result) => setFileContents(result ?? {})) - .catch(() => setFileContents({})); - }, [filenamesKey]); - if (changes.length === 0) { return (
@@ -53,13 +42,16 @@ export function DiffSection({ changes }: DiffSectionProps) { return (
- {[...byFile.entries()].map(([filename, fileChanges], index) => ( + {[...byFile.entries()].map(([filename, fileChanges]) => ( + onOpenFilesChange({ ...openFiles, [filename]: open }) + } /> ))}
diff --git a/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx b/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx index a488bc685..f560259cb 100644 --- a/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx +++ b/apps/native/src/components/widget/summaries/full-file-diff-editor.stories.tsx @@ -3,9 +3,19 @@ import preview from "#storybook/preview"; import { useWidgetStore } from "@/stores/widget-store"; import type { ChangeWithRichType } from "@/components/widget/utils"; import type { FileDiffContents } from "@/types/shared"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { FullFileDiffEditor } from "./full-file-diff-editor"; +function ControlledFullFileDiffEditor({ + initialOpen = false, + ...props +}: Omit, "isOpen" | "onOpenChange"> & { + initialOpen?: boolean; +}) { + const [isOpen, setIsOpen] = useState(initialOpen); + return ; +} + const meta = preview.meta({ title: "Widget/Summaries/FullFileDiffEditor", component: FullFileDiffEditor, @@ -107,11 +117,11 @@ function WithStore({ children }: { children: React.ReactNode }) { export const SingleHunk = meta.story({ render: () => ( - ), @@ -120,11 +130,11 @@ export const SingleHunk = meta.story({ export const MultipleHunks = meta.story({ render: () => ( - ), @@ -133,7 +143,7 @@ export const MultipleHunks = meta.story({ export const Collapsed = meta.story({ render: () => ( - ( - ), diff --git a/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx b/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx index 050ac56d7..8da18def9 100644 --- a/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx +++ b/apps/native/src/components/widget/summaries/full-file-diff-editor.tsx @@ -1,7 +1,7 @@ import { getShortFilename, type ChangeWithRichType } from "@/components/widget/utils"; import type { FileDiffContents } from "@/types/shared"; import type { editor } from "monaco-editor"; -import { useRef, useState } from "react"; +import { useRef } from "react"; import { CollapsibleDiff } from "./collapsible-diff"; import { HunkPill } from "./hunk-pill"; import { DiffView } from "./diff-view"; @@ -17,12 +17,12 @@ interface FullFileDiffEditorProps { filename: string; changes: ChangeWithRichType[]; contents?: FileDiffContents; - defaultOpen?: boolean; + isOpen: boolean; + onOpenChange: (open: boolean) => void; } -export function FullFileDiffEditor({ filename, changes, contents, defaultOpen }: FullFileDiffEditorProps) { +export function FullFileDiffEditor({ filename, changes, contents, isOpen, onOpenChange }: FullFileDiffEditorProps) { const editorRef = useRef(null); - const [isOpen, setIsOpen] = useState(defaultOpen ?? false); const scrollToChange = (index: number) => { const line = getModStartLine(changes[index].diff); @@ -31,7 +31,7 @@ export function FullFileDiffEditor({ filename, changes, contents, defaultOpen }: const handlePillClick = (index: number) => { if (!isOpen) { - setIsOpen(true); + onOpenChange(true); setTimeout(() => scrollToChange(index), 150); } else { scrollToChange(index); @@ -40,10 +40,10 @@ export function FullFileDiffEditor({ filename, changes, contents, defaultOpen }: const handleToggle = () => { if (!isOpen) { - setIsOpen(true); + onOpenChange(true); setTimeout(() => scrollToChange(0), 150); } else { - setIsOpen(false); + onOpenChange(false); editorRef.current?.setModel(null); } }; diff --git a/apps/native/src/components/widget/summaries/summary-or-diff.tsx b/apps/native/src/components/widget/summaries/summary-or-diff.tsx index 82aca865a..3d4088a14 100644 --- a/apps/native/src/components/widget/summaries/summary-or-diff.tsx +++ b/apps/native/src/components/widget/summaries/summary-or-diff.tsx @@ -25,6 +25,7 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { const evolveState = useWidgetStore((s) => s.evolveState); const { summarizeOnFocus } = useSummary(); const [activeTab, setActiveTab] = useState("summary"); + const [openFiles, setOpenFiles] = useState>({}); useEffect(() => { window.addEventListener("focus", summarizeOnFocus); @@ -77,7 +78,11 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { )} {activeTab === "diff" && ( - + )} diff --git a/apps/native/src/hooks/use-git-operations.ts b/apps/native/src/hooks/use-git-operations.ts index c40d30fbf..9ff303084 100644 --- a/apps/native/src/hooks/use-git-operations.ts +++ b/apps/native/src/hooks/use-git-operations.ts @@ -7,6 +7,25 @@ import { toast } from "sonner"; * Hook for git operations. * Provides functions for refreshing git status and stashing changes. */ +const prefetchFileDiffContents = async (status: { changes: { filename: string }[] } | null) => { + const setFileDiffContents = useWidgetStore.getState().setFileDiffContents; + if (!status) { + setFileDiffContents({}); + return; + } + const filenames = [...new Set(status.changes.map((c) => c.filename))]; + if (filenames.length === 0) { + setFileDiffContents({}); + return; + } + try { + const result = await darwinAPI.git.fileDiffContents(filenames); + setFileDiffContents(result ?? {}); + } catch { + setFileDiffContents({}); + } +}; + const refreshGitStatus = async (options?: { cache?: boolean }) => { try { const shouldCache = options?.cache === true; @@ -15,6 +34,7 @@ const refreshGitStatus = async (options?: { cache?: boolean }) => { : await darwinAPI.git.status(); useWidgetStore.getState().setGitStatus(status); + prefetchFileDiffContents(status); return status; } catch (e: unknown) { @@ -34,6 +54,7 @@ const getInitialStatus = async () => { try { const currentStatus = await darwinAPI.git.statusAndCache(); useWidgetStore.getState().setGitStatus(currentStatus); + prefetchFileDiffContents(currentStatus); } catch (e: unknown) { const msg = (e as Error)?.message || String(e); useWidgetStore.getState().setError(msg); diff --git a/apps/native/src/stores/widget-store.impl.ts b/apps/native/src/stores/widget-store.impl.ts index a4b583fc0..d14fa371f 100644 --- a/apps/native/src/stores/widget-store.impl.ts +++ b/apps/native/src/stores/widget-store.impl.ts @@ -2,6 +2,7 @@ import { computeCurrentStep } from "@/components/widget/utils"; import type { EvolveEvent, EvolveState, + FileDiffContents, GitStatus, HistoryItem, PermissionsState, @@ -91,6 +92,7 @@ export interface WidgetState { // Git (from backend) gitStatus: GitStatus | null; + fileDiffContents: Record; // Evolution evolvePrompt: string; isProcessing: boolean; @@ -184,6 +186,7 @@ interface WidgetActions { setEvolveState: (state: EvolveState | null) => void; setExternalBuildDetected: (detected: boolean) => void; setGitStatus: (status: GitStatus | null) => void; + setFileDiffContents: (contents: Record) => void; setEvolvePrompt: (prompt: string) => void; setProcessing: (isProcessing: boolean, action?: ProcessingAction) => void; setChangeMap: (map: SemanticChangeMap | null) => void; @@ -293,6 +296,7 @@ const initialWidgetState: WidgetState = { // Git gitStatus: null, + fileDiffContents: {}, // Evolution evolvePrompt: "", @@ -381,6 +385,7 @@ export function createWidgetStore(initialState?: Partial) { setEvolveState: (evolveState) => set({ evolveState: evolveState }), setExternalBuildDetected: (externalBuildDetected) => set({ externalBuildDetected }), setGitStatus: (gitStatus) => set({ gitStatus }), + setFileDiffContents: (fileDiffContents) => set({ fileDiffContents }), setEvolvePrompt: (evolvePrompt) => set({ evolvePrompt }), setProcessing: (isProcessing, action = null) => set({ From 32c2c2cdfee4b3951e6630f3c2304a283888b0a0 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Fri, 8 May 2026 20:54:34 +0900 Subject: [PATCH 09/15] chore: remove kibo folder after dev merge --- .../__snapshots__/code-block.stories.tsx.snap | 3 - .../kibo-ui/code-block/code-block.stories.tsx | 78 ------------------- 2 files changed, 81 deletions(-) delete mode 100644 apps/native/src/components/kibo-ui/code-block/__snapshots__/code-block.stories.tsx.snap delete mode 100644 apps/native/src/components/kibo-ui/code-block/code-block.stories.tsx diff --git a/apps/native/src/components/kibo-ui/code-block/__snapshots__/code-block.stories.tsx.snap b/apps/native/src/components/kibo-ui/code-block/__snapshots__/code-block.stories.tsx.snap deleted file mode 100644 index dccf55fa2..000000000 --- a/apps/native/src/components/kibo-ui/code-block/__snapshots__/code-block.stories.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Multi File 1`] = `"
flake.nix
{  description = "nixmac demo";  outputs = { self, nixpkgs }: {    darwinConfigurations.demo = nixpkgs.lib.darwinSystem {      modules = [ ./darwin-configuration.nix ];    };  };}
"`; diff --git a/apps/native/src/components/kibo-ui/code-block/code-block.stories.tsx b/apps/native/src/components/kibo-ui/code-block/code-block.stories.tsx deleted file mode 100644 index 68caf5172..000000000 --- a/apps/native/src/components/kibo-ui/code-block/code-block.stories.tsx +++ /dev/null @@ -1,78 +0,0 @@ -// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) -import preview from "#storybook/preview"; -import { - type BundledLanguage, - CodeBlock, - CodeBlockBody, - CodeBlockContent, - CodeBlockCopyButton, - CodeBlockFiles, - CodeBlockFilename, - CodeBlockHeader, - CodeBlockItem, - CodeBlockSelect, - CodeBlockSelectContent, - CodeBlockSelectItem, - CodeBlockSelectTrigger, - CodeBlockSelectValue, -} from "./index"; - -const meta = preview.meta({ - title: "Kibo UI/CodeBlock", - component: CodeBlock, - parameters: { layout: "centered" }, - tags: ["autodocs"], -}); - -export default meta; - -const files = [ - { - language: "nix", - filename: "flake.nix", - code: `{\n description = \"nixmac demo\";\n\n outputs = { self, nixpkgs }: {\n darwinConfigurations.demo = nixpkgs.lib.darwinSystem {\n modules = [ ./darwin-configuration.nix ];\n };\n };\n}`, - }, - { - language: "typescript", - filename: "settings.ts", - code: `export const provider = \"codex\";\nexport const maxIterations = 25;\n\nexport function ready() {\n return provider.length > 0;\n}`, - }, -]; - -export const MultiFile = meta.story({ - render: () => ( - - - - {(item) => ( - - {item.filename} - - )} - - - - - - - {(item) => ( - - {item.filename} - - )} - - - - - - {(item) => ( - - - {item.code} - - - )} - - - ), -}); From 886e3d62ae3a8c19e09284d2f46651d91cc0e991 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Fri, 8 May 2026 21:25:34 +0900 Subject: [PATCH 10/15] chore(storybook): stub monaco and update snapshots --- apps/native/.storybook/vitest.setup.ts | 7 +++++++ .../rebuild-overlay-panel.stories.tsx.snap | 6 +++--- .../__snapshots__/collapsible-diff.stories.tsx.snap | 10 +++++----- .../__snapshots__/diff-section.stories.tsx.snap | 4 ++-- .../full-file-diff-editor.stories.tsx.snap | 6 +++--- 5 files changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/native/.storybook/vitest.setup.ts b/apps/native/.storybook/vitest.setup.ts index c752859ba..a750c2f1d 100644 --- a/apps/native/.storybook/vitest.setup.ts +++ b/apps/native/.storybook/vitest.setup.ts @@ -31,6 +31,13 @@ function normalizeSnapshotRoot(root: Element): string { editor.appendChild(placeholder); } + for (const editor of clone.querySelectorAll(".monaco-diff-editor, .monaco-editor")) { + editor.replaceChildren(); + const placeholder = document.createElement("div"); + placeholder.setAttribute("data-slot", "monaco-editor-placeholder"); + editor.appendChild(placeholder); + } + let html = normalizeAnimations(clone.innerHTML); // Monaco assigns auto-incrementing model IDs that vary by test-suite order. html = html.replace(/inmemory:\/\/model\/\d+/g, "inmemory://model/N"); diff --git a/apps/native/src/components/widget/overlays/__snapshots__/rebuild-overlay-panel.stories.tsx.snap b/apps/native/src/components/widget/overlays/__snapshots__/rebuild-overlay-panel.stories.tsx.snap index 4b057e581..1a88cd026 100644 --- a/apps/native/src/components/widget/overlays/__snapshots__/rebuild-overlay-panel.stories.tsx.snap +++ b/apps/native/src/components/widget/overlays/__snapshots__/rebuild-overlay-panel.stories.tsx.snap @@ -8,12 +8,12 @@ exports[`Evaluation Error 1`] = `"

Build Failed

An unexpected error occurred during the rebuild process

The build encountered an error. You can rollback to your previous configuration or dismiss to investigate.

Starting rebuild...
Evaluating flake configuration
Build failed: infinite recursion
🚀 Starting rebuild...
📦 Evaluating flake configuration
❌ Build failed: infinite recursion
"`; -exports[`Infinite Recursion Error 1`] = `"

Infinite Recursion Detected

error: infinite recursion encountered at /nix/store/...-source/flake.nix:42

Your configuration has a circular dependency. Rolling back will restore your previous working configuration.

Starting rebuild...
Evaluating flake configuration
Build failed: infinite recursion
🚀 Starting rebuild...
📦 Evaluating flake configuration
❌ Build failed: infinite recursion
"`; +exports[`Infinite Recursion Error 1`] = `"

Infinite Recursion Detected

error: infinite recursion encountered at /nix/store/...-source/flake.nix:42

Your configuration has a circular dependency. Rolling back will restore your previous working configuration.

Starting rebuild...
Evaluating flake configuration
Build failed: infinite recursion
🚀 Starting rebuild...
📦 Evaluating flake configuration
❌ Build failed: infinite recursion
"`; exports[`Many Lines 1`] = `"
Starting rebuild...
Evaluating flake configuration
Building 24 packages
📥 Fetching from binary cache
Compiling neovim
🔧 Building home-manager
Installing ripgrep
🎯 Configuring git
✨ Setting up zsh plugins
Building starship prompt
🚀 Starting rebuild...
📦 Evaluating flake configuration
🔨 Building 24 packages
📥 Fetching from binary cache
⚡ Compiling neovim
🔧 Building home-manager
📦 Installing ripgrep
🎯 Configuring git
✨ Setting up zsh plugins
🔨 Building starship prompt
"`; -exports[`Mid Build 1`] = `"
Starting rebuild...
Evaluating flake configuration
Building 12 packages
📥 Fetching dependencies from cache
Compiling neovim plugins
🚀 Starting rebuild...
📦 Evaluating flake configuration
🔨 Building 12 packages
📥 Fetching dependencies from cache
⚡ Compiling neovim plugins
"`; +exports[`Mid Build 1`] = `"
Starting rebuild...
Evaluating flake configuration
Building 12 packages
📥 Fetching dependencies from cache
Compiling neovim plugins
🚀 Starting rebuild...
📦 Evaluating flake configuration
🔨 Building 12 packages
📥 Fetching dependencies from cache
⚡ Compiling neovim plugins
"`; -exports[`Starting 1`] = `"
Starting rebuild...
🚀 Starting rebuild...
"`; +exports[`Starting 1`] = `"
Starting rebuild...
🚀 Starting rebuild...
"`; exports[`Success 1`] = `"
Starting rebuild...
Evaluating flake configuration
Building 12 packages
📥 Fetching dependencies from cache
Compiling neovim plugins
🔧 Activating system configuration
Rebuild complete!
🚀 Starting rebuild...
📦 Evaluating flake configuration
🔨 Building 12 packages
📥 Fetching dependencies from cache
⚡ Compiling neovim plugins
🔧 Activating system configuration
✅ Rebuild complete!
"`; diff --git a/apps/native/src/components/widget/summaries/__snapshots__/collapsible-diff.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/collapsible-diff.stories.tsx.snap index e496de992..7f9ea2dd3 100644 --- a/apps/native/src/components/widget/summaries/__snapshots__/collapsible-diff.stories.tsx.snap +++ b/apps/native/src/components/widget/summaries/__snapshots__/collapsible-diff.stories.tsx.snap @@ -1,11 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Collapsed Edited 1`] = `"
modules/darwin/packages.nix
"`; +exports[`Collapsed Edited 1`] = `"
modules/darwin/packages.nix
"`; -exports[`Open New 1`] = `"
modules/darwin/fonts.nix
Diff content here
"`; +exports[`Open New 1`] = `"
modules/darwin/fonts.nix
Diff content here
"`; -exports[`Removed 1`] = `"
modules/home/old-shell.nix
"`; +exports[`Removed 1`] = `"
modules/home/old-shell.nix
"`; -exports[`Renamed 1`] = `"
modules/darwin/brew.nix
"`; +exports[`Renamed 1`] = `"
modules/darwin/brew.nix
"`; -exports[`With Header Extra 1`] = `"
modules/darwin/packages.nix
+3 -1
Diff content here
"`; +exports[`With Header Extra 1`] = `"
modules/darwin/packages.nix
+3 -1
Diff content here
"`; diff --git a/apps/native/src/components/widget/summaries/__snapshots__/diff-section.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/diff-section.stories.tsx.snap index 6d1258b1b..e1a00ef00 100644 --- a/apps/native/src/components/widget/summaries/__snapshots__/diff-section.stories.tsx.snap +++ b/apps/native/src/components/widget/summaries/__snapshots__/diff-section.stories.tsx.snap @@ -2,6 +2,6 @@ exports[`Empty 1`] = `"
No diff available
"`; -exports[`Multiple Files 1`] = `"
modules/darwin/packages.nix
+2
Loading...
modules/home/shell.nix
+5
flake.nix
+1
"`; +exports[`Multiple Files 1`] = `"
modules/darwin/packages.nix
+2
modules/home/shell.nix
+5
flake.nix
+1
"`; -exports[`Single File 1`] = `"
modules/darwin/packages.nix
+2
Loading...
"`; +exports[`Single File 1`] = `"
modules/darwin/packages.nix
+2
"`; diff --git a/apps/native/src/components/widget/summaries/__snapshots__/full-file-diff-editor.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/full-file-diff-editor.stories.tsx.snap index 684514317..30bef5fed 100644 --- a/apps/native/src/components/widget/summaries/__snapshots__/full-file-diff-editor.stories.tsx.snap +++ b/apps/native/src/components/widget/summaries/__snapshots__/full-file-diff-editor.stories.tsx.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Collapsed 1`] = `"
configuration.nix
Add ripgrep, fd, jqEnable flakes
"`; +exports[`Collapsed 1`] = `"
configuration.nix
Add ripgrep, fd, jqEnable flakes
"`; -exports[`Loading 1`] = `"
configuration.nix
Add ripgrep, fd, jq
Loading...
"`; +exports[`Loading 1`] = `"
configuration.nix
Add ripgrep, fd, jq
Loading...
"`; -exports[`Multiple Hunks 1`] = `"
configuration.nix
Add ripgrep, fd, jqEnable flakes
"`; +exports[`Multiple Hunks 1`] = `"
configuration.nix
Add ripgrep, fd, jqEnable flakes
"`; exports[`Single Hunk 1`] = `"
"`; From 8a599050343269f8e42eac260c391bd71490d4ed Mon Sep 17 00:00:00 2001 From: CasLinden Date: Fri, 8 May 2026 21:32:56 +0900 Subject: [PATCH 11/15] chore(storybook): separate stories for monaco editor wrappers --- .../rebuild-overlay-panel.stories.tsx.snap | 6 +- .../__snapshots__/diff-view.stories.tsx.snap | 5 ++ .../__snapshots__/file-view.stories.tsx.snap | 7 +++ .../widget/summaries/diff-view.stories.tsx | 59 +++++++++++++++++ .../widget/summaries/file-view.stories.tsx | 63 +++++++++++++++++++ 5 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 apps/native/src/components/widget/summaries/__snapshots__/diff-view.stories.tsx.snap create mode 100644 apps/native/src/components/widget/summaries/__snapshots__/file-view.stories.tsx.snap create mode 100644 apps/native/src/components/widget/summaries/diff-view.stories.tsx create mode 100644 apps/native/src/components/widget/summaries/file-view.stories.tsx diff --git a/apps/native/src/components/widget/overlays/__snapshots__/rebuild-overlay-panel.stories.tsx.snap b/apps/native/src/components/widget/overlays/__snapshots__/rebuild-overlay-panel.stories.tsx.snap index 1a88cd026..1e5723980 100644 --- a/apps/native/src/components/widget/overlays/__snapshots__/rebuild-overlay-panel.stories.tsx.snap +++ b/apps/native/src/components/widget/overlays/__snapshots__/rebuild-overlay-panel.stories.tsx.snap @@ -8,12 +8,12 @@ exports[`Evaluation Error 1`] = `"

Build Failed

An unexpected error occurred during the rebuild process

The build encountered an error. You can rollback to your previous configuration or dismiss to investigate.

Starting rebuild...
Evaluating flake configuration
Build failed: infinite recursion
🚀 Starting rebuild...
📦 Evaluating flake configuration
❌ Build failed: infinite recursion
"`; -exports[`Infinite Recursion Error 1`] = `"

Infinite Recursion Detected

error: infinite recursion encountered at /nix/store/...-source/flake.nix:42

Your configuration has a circular dependency. Rolling back will restore your previous working configuration.

Starting rebuild...
Evaluating flake configuration
Build failed: infinite recursion
🚀 Starting rebuild...
📦 Evaluating flake configuration
❌ Build failed: infinite recursion
"`; +exports[`Infinite Recursion Error 1`] = `"

Infinite Recursion Detected

error: infinite recursion encountered at /nix/store/...-source/flake.nix:42

Your configuration has a circular dependency. Rolling back will restore your previous working configuration.

Starting rebuild...
Evaluating flake configuration
Build failed: infinite recursion
🚀 Starting rebuild...
📦 Evaluating flake configuration
❌ Build failed: infinite recursion
"`; exports[`Many Lines 1`] = `"
Starting rebuild...
Evaluating flake configuration
Building 24 packages
📥 Fetching from binary cache
Compiling neovim
🔧 Building home-manager
Installing ripgrep
🎯 Configuring git
✨ Setting up zsh plugins
Building starship prompt
🚀 Starting rebuild...
📦 Evaluating flake configuration
🔨 Building 24 packages
📥 Fetching from binary cache
⚡ Compiling neovim
🔧 Building home-manager
📦 Installing ripgrep
🎯 Configuring git
✨ Setting up zsh plugins
🔨 Building starship prompt
"`; exports[`Mid Build 1`] = `"
Starting rebuild...
Evaluating flake configuration
Building 12 packages
📥 Fetching dependencies from cache
Compiling neovim plugins
🚀 Starting rebuild...
📦 Evaluating flake configuration
🔨 Building 12 packages
📥 Fetching dependencies from cache
⚡ Compiling neovim plugins
"`; -exports[`Starting 1`] = `"
Starting rebuild...
🚀 Starting rebuild...
"`; +exports[`Starting 1`] = `"
Starting rebuild...
🚀 Starting rebuild...
"`; -exports[`Success 1`] = `"
Starting rebuild...
Evaluating flake configuration
Building 12 packages
📥 Fetching dependencies from cache
Compiling neovim plugins
🔧 Activating system configuration
Rebuild complete!
🚀 Starting rebuild...
📦 Evaluating flake configuration
🔨 Building 12 packages
📥 Fetching dependencies from cache
⚡ Compiling neovim plugins
🔧 Activating system configuration
✅ Rebuild complete!
"`; +exports[`Success 1`] = `"
Starting rebuild...
Evaluating flake configuration
Building 12 packages
📥 Fetching dependencies from cache
Compiling neovim plugins
🔧 Activating system configuration
Rebuild complete!
🚀 Starting rebuild...
📦 Evaluating flake configuration
🔨 Building 12 packages
📥 Fetching dependencies from cache
⚡ Compiling neovim plugins
🔧 Activating system configuration
✅ Rebuild complete!
"`; diff --git a/apps/native/src/components/widget/summaries/__snapshots__/diff-view.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/diff-view.stories.tsx.snap new file mode 100644 index 000000000..585604fca --- /dev/null +++ b/apps/native/src/components/widget/summaries/__snapshots__/diff-view.stories.tsx.snap @@ -0,0 +1,5 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Default 1`] = `"
"`; + +exports[`Identical 1`] = `"
"`; diff --git a/apps/native/src/components/widget/summaries/__snapshots__/file-view.stories.tsx.snap b/apps/native/src/components/widget/summaries/__snapshots__/file-view.stories.tsx.snap new file mode 100644 index 000000000..b89f6efbb --- /dev/null +++ b/apps/native/src/components/widget/summaries/__snapshots__/file-view.stories.tsx.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`New Json File 1`] = `"
"`; + +exports[`New Nix File 1`] = `"
"`; + +exports[`Removed Nix File 1`] = `"
"`; diff --git a/apps/native/src/components/widget/summaries/diff-view.stories.tsx b/apps/native/src/components/widget/summaries/diff-view.stories.tsx new file mode 100644 index 000000000..b116e3249 --- /dev/null +++ b/apps/native/src/components/widget/summaries/diff-view.stories.tsx @@ -0,0 +1,59 @@ +// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) +import preview from "#storybook/preview"; +import type { FileDiffContents } from "@/types/shared"; +import { DiffView } from "./diff-view"; + +const meta = preview.meta({ + title: "Widget/Summaries/DiffView", + component: DiffView, + parameters: { layout: "padded" }, + tags: ["autodocs"], +}); + +export default meta; + +const ORIGINAL = `{ config, pkgs, ... }: + +{ + environment.systemPackages = with pkgs; [ + vim + git + ]; +}`; + +const MODIFIED = `{ config, pkgs, ... }: + +{ + environment.systemPackages = with pkgs; [ + vim + git + ripgrep + fd + jq + ]; +}`; + +const mockContents: FileDiffContents = { + original: ORIGINAL, + modified: MODIFIED, +}; + +export const Default = meta.story({ + render: () => ( +
+ {}} /> +
+ ), +}); + +export const Identical = meta.story({ + render: () => ( +
+ {}} + /> +
+ ), +}); diff --git a/apps/native/src/components/widget/summaries/file-view.stories.tsx b/apps/native/src/components/widget/summaries/file-view.stories.tsx new file mode 100644 index 000000000..bc13e8089 --- /dev/null +++ b/apps/native/src/components/widget/summaries/file-view.stories.tsx @@ -0,0 +1,63 @@ +// @ts-nocheck - Storybook 10 alpha types have inference issues (resolves to `never`) +import preview from "#storybook/preview"; +import type { FileDiffContents } from "@/types/shared"; +import { FileView } from "./file-view"; + +const meta = preview.meta({ + title: "Widget/Summaries/FileView", + component: FileView, + parameters: { layout: "padded" }, + tags: ["autodocs"], +}); + +export default meta; + +const NIX_CONTENT = `{ config, pkgs, ... }: + +{ + fonts.packages = with pkgs; [ + jetbrains-mono + ]; +}`; + +const JSON_CONTENT = `{ + "editor.fontSize": 13, + "editor.tabSize": 2, + "editor.formatOnSave": true +}`; + +export const NewNixFile = meta.story({ + render: () => ( +
+ +
+ ), +}); + +export const NewJsonFile = meta.story({ + render: () => ( +
+ +
+ ), +}); + +export const RemovedNixFile = meta.story({ + render: () => ( +
+ +
+ ), +}); From 00bea3d3be5147dc827e1ba5049a0854225f892f Mon Sep 17 00:00:00 2001 From: CasLinden Date: Fri, 15 May 2026 20:26:07 +0900 Subject: [PATCH 12/15] chore(types): commit auto generated search package added by scott on rust side --- apps/native/src/types/shared.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/native/src/types/shared.ts b/apps/native/src/types/shared.ts index 3e858f288..590ff9f41 100644 --- a/apps/native/src/types/shared.ts +++ b/apps/native/src/types/shared.ts @@ -406,7 +406,7 @@ export type EvolveEventType = /** * Agent is reading a file. */ -"reading" | +"reading" | "searchPackages" | /** * Agent is editing a file. */ @@ -689,6 +689,11 @@ nixVersion: string | null; */ appVersion: string | null } +/** + * HEAD content vs working-tree content for a file, used by the diff tab Monaco DiffEditor. + */ +export type FileDiffContents = { original: string; modified: string } + /** * File or directory entry returned by the editor tree. */ @@ -719,11 +724,6 @@ gitStatus: GitStatus; */ evolveState: EvolveState } -/** - * HEAD content vs working-tree content for a file, used by the diff tab Monaco DiffEditor. - */ -export type FileDiffContents = { original: string; modified: string } - /** * Individual file status parsed from diff headers. */ From ea824bbaeca622e15f7c624ff5f966026aeab116 Mon Sep 17 00:00:00 2001 From: CasLinden Date: Fri, 15 May 2026 21:11:28 +0900 Subject: [PATCH 13/15] fix(diffs): read diff file content from repo root --- apps/native/src-tauri/src/git/exec.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/native/src-tauri/src/git/exec.rs b/apps/native/src-tauri/src/git/exec.rs index 046b5f500..b9848d8a4 100644 --- a/apps/native/src-tauri/src/git/exec.rs +++ b/apps/native/src-tauri/src/git/exec.rs @@ -115,6 +115,15 @@ pub fn get_nix_diff(dir: &str) -> Result { Ok(diff) } +pub fn repo_root(dir: &str) -> String { + git_command() + .args(["rev-parse", "--show-toplevel"]) + .current_dir(dir) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|_| dir.to_string()) +} + /// Returns (original, modified) file content for a single file: HEAD content and working-tree content. /// Returns empty strings for new files (no HEAD) or deleted files (not on disk). pub fn file_diff_contents(dir: &str, filename: &str) -> (String, String) { @@ -124,7 +133,7 @@ pub fn file_diff_contents(dir: &str, filename: &str) -> (String, String) { .output() .map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) .unwrap_or_default(); - let modified = std::fs::read_to_string(std::path::Path::new(dir).join(filename)) + let modified = std::fs::read_to_string(std::path::Path::new(&repo_root(dir)).join(filename)) .unwrap_or_default(); (original, modified) } From 0670c131766cafc703db0d033683cde5e357885a Mon Sep 17 00:00:00 2001 From: CasLinden Date: Fri, 15 May 2026 21:20:03 +0900 Subject: [PATCH 14/15] fix(diffs): refetch file diff content when changed files change with memo --- .../components/widget/summaries/summary-or-diff.tsx | 13 ++++++++++++- apps/native/src/hooks/use-git-operations.ts | 4 +--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/native/src/components/widget/summaries/summary-or-diff.tsx b/apps/native/src/components/widget/summaries/summary-or-diff.tsx index 3d4088a14..ee6eb32c2 100644 --- a/apps/native/src/components/widget/summaries/summary-or-diff.tsx +++ b/apps/native/src/components/widget/summaries/summary-or-diff.tsx @@ -7,12 +7,13 @@ import { import { Tabs } from "@/components/ui/tabs"; import { DiffSection } from "@/components/widget/summaries/diff-section"; import { SummaryItems } from "@/components/widget/summaries/summary-items"; +import { prefetchFileDiffContents } from "@/hooks/use-git-operations"; import { useSummary } from "@/hooks/use-summary"; import { cn } from "@/lib/utils"; import { useWidgetStore } from "@/stores/widget-store"; import type { Change } from "@/types/shared.ts"; import { Dna, Wrench } from "lucide-react"; -import { Activity, useEffect, useState } from "react"; +import { Activity, useEffect, useMemo, useState } from "react"; import { enrichChanges } from "../utils"; interface SummaryOrDiffProps { @@ -32,6 +33,16 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { return () => window.removeEventListener("focus", summarizeOnFocus); }, [summarizeOnFocus]); + const fileDiffKey = useMemo( + () => + gitStatus?.changes.map((c) => `${c.filename}:${c.hash}`).sort().join("\n") ?? "", + [gitStatus], + ); + + useEffect(() => { + prefetchFileDiffContents(useWidgetStore.getState().gitStatus); + }, [fileDiffKey]); + if (!gitStatus || !evolveState || evolveState.step === "begin") { return null; } diff --git a/apps/native/src/hooks/use-git-operations.ts b/apps/native/src/hooks/use-git-operations.ts index 9ff303084..79f134ffe 100644 --- a/apps/native/src/hooks/use-git-operations.ts +++ b/apps/native/src/hooks/use-git-operations.ts @@ -7,7 +7,7 @@ import { toast } from "sonner"; * Hook for git operations. * Provides functions for refreshing git status and stashing changes. */ -const prefetchFileDiffContents = async (status: { changes: { filename: string }[] } | null) => { +export const prefetchFileDiffContents = async (status: { changes: { filename: string }[] } | null) => { const setFileDiffContents = useWidgetStore.getState().setFileDiffContents; if (!status) { setFileDiffContents({}); @@ -34,7 +34,6 @@ const refreshGitStatus = async (options?: { cache?: boolean }) => { : await darwinAPI.git.status(); useWidgetStore.getState().setGitStatus(status); - prefetchFileDiffContents(status); return status; } catch (e: unknown) { @@ -54,7 +53,6 @@ const getInitialStatus = async () => { try { const currentStatus = await darwinAPI.git.statusAndCache(); useWidgetStore.getState().setGitStatus(currentStatus); - prefetchFileDiffContents(currentStatus); } catch (e: unknown) { const msg = (e as Error)?.message || String(e); useWidgetStore.getState().setError(msg); From 6e769c4a57a216d4023235c25b317d259322e2fb Mon Sep 17 00:00:00 2001 From: CasLinden Date: Fri, 15 May 2026 21:45:10 +0900 Subject: [PATCH 15/15] feat(diffs): prefer the diff tab, if it please --- apps/native/src-tauri/src/commands/ui_prefs.rs | 9 +++++++++ apps/native/src-tauri/src/shared_types/prefs.rs | 4 ++++ apps/native/src-tauri/src/storage/store.rs | 3 +++ .../components/widget/settings/preferences-tab.tsx | 11 +++++++++++ .../components/widget/summaries/summary-or-diff.tsx | 5 +++-- apps/native/src/hooks/use-prefs.ts | 3 +++ apps/native/src/stores/widget-store.impl.ts | 8 +++++++- apps/native/src/types/shared.ts | 8 ++++++++ 8 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/native/src-tauri/src/commands/ui_prefs.rs b/apps/native/src-tauri/src/commands/ui_prefs.rs index 240c2b298..69d6bcd30 100644 --- a/apps/native/src-tauri/src/commands/ui_prefs.rs +++ b/apps/native/src-tauri/src/commands/ui_prefs.rs @@ -57,6 +57,10 @@ pub async fn ui_get_prefs(app: AppHandle) -> Result Result, /// Startup Homebrew scan preference update. pub scan_homebrew_on_startup: Option, + /// Default-to-diff-tab preference update. + pub default_to_diff_tab: Option, /// Developer mode preference update. pub developer_mode: Option, /// `None` -> field not sent; `Some(None)` -> clear the pinned version. diff --git a/apps/native/src-tauri/src/storage/store.rs b/apps/native/src-tauri/src/storage/store.rs index 4d05ac4b4..2bd3af67c 100644 --- a/apps/native/src-tauri/src/storage/store.rs +++ b/apps/native/src-tauri/src/storage/store.rs @@ -34,6 +34,9 @@ pub const AUTO_SUMMARIZE_ON_FOCUS_KEY: &str = "autoSummarizeOnFocus"; // Startup scan preference keys pub const SCAN_HOMEBREW_ON_STARTUP_KEY: &str = "scanHomebrewOnStartup"; +// Default-tab preference keys +pub const DEFAULT_TO_DIFF_TAB_KEY: &str = "defaultToDiffTab"; + // Developer-mode preference keys pub const DEVELOPER_MODE_KEY: &str = "developerMode"; pub const PINNED_VERSION_KEY: &str = "pinnedVersion"; diff --git a/apps/native/src/components/widget/settings/preferences-tab.tsx b/apps/native/src/components/widget/settings/preferences-tab.tsx index aee6b5b1b..d40a1c67c 100644 --- a/apps/native/src/components/widget/settings/preferences-tab.tsx +++ b/apps/native/src/components/widget/settings/preferences-tab.tsx @@ -9,6 +9,7 @@ export function PreferencesTab() { const confirmRollback = useWidgetStore((s) => s.confirmRollback); const autoSummarizeOnFocus = useWidgetStore((s) => s.autoSummarizeOnFocus); const scanHomebrewOnStartup = useWidgetStore((s) => s.scanHomebrewOnStartup); + const defaultToDiffTab = useWidgetStore((s) => s.defaultToDiffTab); return (
@@ -64,6 +65,16 @@ export function PreferencesTab() { onCheckedChange={(checked) => setPref("autoSummarizeOnFocus", checked)} />
+
+
+
Diff Tab
+
Prefer Diff tab when reviewing changes
+
+ setPref("defaultToDiffTab", checked)} + /> +
diff --git a/apps/native/src/components/widget/summaries/summary-or-diff.tsx b/apps/native/src/components/widget/summaries/summary-or-diff.tsx index ee6eb32c2..259f5e6eb 100644 --- a/apps/native/src/components/widget/summaries/summary-or-diff.tsx +++ b/apps/native/src/components/widget/summaries/summary-or-diff.tsx @@ -24,8 +24,9 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { const gitStatus = useWidgetStore((s) => s.gitStatus); const changeMap = useWidgetStore((s) => s.changeMap); const evolveState = useWidgetStore((s) => s.evolveState); + const defaultToDiffTab = useWidgetStore((s) => s.defaultToDiffTab); const { summarizeOnFocus } = useSummary(); - const [activeTab, setActiveTab] = useState("summary"); + const [activeTab, setActiveTab] = useState(defaultToDiffTab ? "diff" : "summary"); const [openFiles, setOpenFiles] = useState>({}); useEffect(() => { @@ -74,7 +75,7 @@ export function SummaryOrDiff({ variant = "default" }: SummaryOrDiffProps) { : "What's changed"}
- + Summary Diff diff --git a/apps/native/src/hooks/use-prefs.ts b/apps/native/src/hooks/use-prefs.ts index c6ec112bf..ac71255b0 100644 --- a/apps/native/src/hooks/use-prefs.ts +++ b/apps/native/src/hooks/use-prefs.ts @@ -10,6 +10,9 @@ export function usePrefs() { useWidgetStore .getState() .setBoolPref("scanHomebrewOnStartup", prefs.scanHomebrewOnStartup ?? true); + useWidgetStore + .getState() + .setBoolPref("defaultToDiffTab", prefs.defaultToDiffTab ?? false); useWidgetStore.getState().setDeveloperMode(prefs.developerMode ?? false); useWidgetStore.getState().setPinnedVersion(prefs.pinnedVersion ?? null); } diff --git a/apps/native/src/stores/widget-store.impl.ts b/apps/native/src/stores/widget-store.impl.ts index d14fa371f..424dcf599 100644 --- a/apps/native/src/stores/widget-store.impl.ts +++ b/apps/native/src/stores/widget-store.impl.ts @@ -32,7 +32,7 @@ export type SettingsTab = "general" | "api-keys" | "ai-models" | "preferences" | export type WidgetStep = "permissions" | "nix-setup" | "setup" | "begin" | "evolve" | "commit" | "manualEvolve" | "manualCommit" | "history" | "filesystem"; type ProcessingAction = "evolve" | "apply" | "merge" | "cancel" | null; export type ConfirmPrefKey = "confirmBuild" | "confirmClear" | "confirmRollback"; -export type BoolPrefKey = ConfirmPrefKey | "autoSummarizeOnFocus" | "scanHomebrewOnStartup"; +export type BoolPrefKey = ConfirmPrefKey | "autoSummarizeOnFocus" | "scanHomebrewOnStartup" | "defaultToDiffTab"; // Rebuild state for showing progress inline in the widget export type RebuildErrorType = @@ -157,6 +157,9 @@ export interface WidgetState { // Startup scanning preferences scanHomebrewOnStartup: boolean; + // Default-tab preference + defaultToDiffTab: boolean; + // Developer mode (hidden settings panel for bisecting / pinning to a past release) developerMode: boolean; pinnedVersion: string | null; @@ -351,6 +354,9 @@ const initialWidgetState: WidgetState = { // Startup scanning preferences scanHomebrewOnStartup: true, + // Default-tab preference + defaultToDiffTab: false, + // Developer mode developerMode: false, pinnedVersion: null, diff --git a/apps/native/src/types/shared.ts b/apps/native/src/types/shared.ts index 590ff9f41..1f169955a 100644 --- a/apps/native/src/types/shared.ts +++ b/apps/native/src/types/shared.ts @@ -1372,6 +1372,10 @@ autoSummarizeOnFocus: boolean; * Whether Homebrew state should be scanned on app startup. */ scanHomebrewOnStartup: boolean; +/** + * Whether the change view defaults to the Diff tab instead of Summary. + */ +defaultToDiffTab: boolean; /** * Whether developer-only UI/actions are enabled. */ @@ -1454,6 +1458,10 @@ autoSummarizeOnFocus: boolean | null; * Startup Homebrew scan preference update. */ scanHomebrewOnStartup: boolean | null; +/** + * Default-to-diff-tab preference update. + */ +defaultToDiffTab: boolean | null; /** * Developer mode preference update. */