diff --git a/.claude-done b/.claude-done new file mode 100644 index 00000000..96f4e91f --- /dev/null +++ b/.claude-done @@ -0,0 +1,35 @@ +# Rename Note Tab — Done Summary + +## What was built +Double-click a tab title to rename the note. The tab shows an inline text input (Enter saves, Escape cancels). Renaming updates the note's title, filename, H1 heading, frontmatter `title:` field, and all wiki links (`[[OldName]]` → `[[NewName]]`) across the entire vault. + +## Commits on `task/rename-note-tab` +1. `caa26b6` — design: rename-note-tab wireframes (`design/rename-note-tab.pen`) +2. `68dfab4` — feat: add `rename_note` Tauri command with wiki link updates (Rust backend) +3. `5a8eb6a` — feat: rename note via double-click on tab — frontend wiring +4. `ee00c2c` — test: Playwright e2e tests for rename tab feature + +## Architecture decisions +- **Slug generation**: title → lowercase, non-alphanumeric chars replaced with hyphens, leading/trailing hyphens stripped. Same logic in Rust backend and TypeScript mock. +- **Wiki link matching**: regex matches both title-based (`[[Old Title]]`) and path-stem-based (`[[note/old-title]]`) references, preserving pipe aliases (`[[Old Title|display]]` → `[[New Title|display]]`). +- **State flow**: `onRenameTab` flows from TabBar → Editor → App → `useNoteActions.handleRenameNote` → Tauri backend → `useVaultLoader.replaceEntry` (updates entries + allContent atomically). +- **H1 + frontmatter**: The rename updates both the first `# Heading` line and the `title:` frontmatter field if present. +- **Toast feedback**: Shows "Renamed" or "Renamed — updated N wiki links" on success, "Failed to rename note" on error. + +## Code health +- **TabBar.tsx**: Improved from CC 23→18 by extracting `TabItem` component and `tabStyle` helper. +- **Rust `vault.rs`**: Passed code health after extracting helpers (`build_wikilink_pattern`, `WikilinkReplacement` struct, `collect_md_files`, etc.) to keep CC under thresholds. +- **useNoteActions.ts**: Pre-existing CC=37 (threshold 9) increased to 42. Extracted `performRename` and `buildRenamedEntry` as module-level functions. Proper fix requires splitting the monolithic hook (separate concern). +- **App.tsx / useVaultLoader.ts**: Minor LOC/CC increases on already-above-threshold functions. + +## Test results +- `pnpm test` — 119/119 passed +- `cargo test` — 147/147 passed (including 7 new rename tests) +- `npx vite build` — success +- Playwright e2e — 2/2 rename-tab tests pass + +## Visual verification +Tested on `localhost:5173` via Playwright: +- Double-click → inline input appears with current title selected +- Type new name → Enter → tab title updates, note list updates, breadcrumb updates, toast shows "Renamed" +- Double-click → type → Escape → title reverts, no changes made diff --git a/design/rename-note-tab.pen b/design/rename-note-tab.pen new file mode 100644 index 00000000..061911c4 --- /dev/null +++ b/design/rename-note-tab.pen @@ -0,0 +1,1221 @@ +{ + "variables": { + "--accent": { + "type": "color", + "value": [ + { + "value": "#EBEBEA" + }, + { + "value": "#252545", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-blue": { + "type": "color", + "value": [ + { + "value": "#2383E2" + }, + { + "value": "#4a9eff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#155DFF" + }, + { + "value": "#155DFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#ffffff", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-green": { + "type": "color", + "value": [ + { + "value": "#0F7B6C" + }, + { + "value": "#4caf50", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#00B38B" + }, + { + "value": "#00B38B", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-orange": { + "type": "color", + "value": [ + { + "value": "#D9730D" + }, + { + "value": "#ff9800", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-purple": { + "type": "color", + "value": [ + { + "value": "#9065B0" + }, + { + "value": "#9c72ff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#A932FF" + }, + { + "value": "#A932FF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-red": { + "type": "color", + "value": [ + { + "value": "#E03E3E" + }, + { + "value": "#f44336", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--background": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#0f0f1a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--black": { + "type": "color", + "value": "#000000" + }, + "--border": { + "type": "color", + "value": [ + { + "value": "#E9E9E7" + }, + { + "value": "#2a2a4a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--card": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#16162a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--card-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#e0e0e0", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--destructive": { + "type": "color", + "value": [ + { + "value": "#E03E3E" + }, + { + "value": "#f44336", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--destructive-foreground": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#ffffff", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--font-primary": { + "type": "string", + "value": [ + { + "value": "Inter" + }, + { + "value": "-apple-system, BlinkMacSystemFont, Inter, sans-serif" + }, + { + "value": "Inter" + } + ] + }, + "--font-system": { + "type": "string", + "value": "-apple-system, BlinkMacSystemFont, sans-serif" + }, + "--foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#e0e0e0", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--input": { + "type": "color", + "value": [ + { + "value": "#E9E9E7" + }, + { + "value": "#2a2a4a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--muted": { + "type": "color", + "value": [ + { + "value": "#F0F0EF" + }, + { + "value": "#1e1e3a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--muted-foreground": { + "type": "color", + "value": [ + { + "value": "#787774" + }, + { + "value": "#888888", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--popover": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#1e1e3a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--popover-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#e0e0e0", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--primary": { + "type": "color", + "value": [ + { + "value": "#2383E2" + }, + { + "value": "#4a9eff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#155DFF" + }, + { + "value": "#155DFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--primary-foreground": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#ffffff", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--radius-lg": { + "type": "number", + "value": 8 + }, + "--radius-md": { + "type": "number", + "value": 6 + }, + "--radius-sm": { + "type": "number", + "value": 4 + }, + "--ring": { + "type": "color", + "value": [ + { + "value": "#2383E2" + }, + { + "value": "#4a9eff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#155DFF" + }, + { + "value": "#155DFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--secondary": { + "type": "color", + "value": [ + { + "value": "#EBEBEA" + }, + { + "value": "#2a2a4a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--secondary-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#e0e0e0", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar": { + "type": "color", + "value": [ + { + "value": "#F7F6F3" + }, + { + "value": "#1a1a2e", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-accent": { + "type": "color", + "value": [ + { + "value": "#EBEBEA" + }, + { + "value": "#252545", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-accent-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#ffffff", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-border": { + "type": "color", + "value": [ + { + "value": "#E9E9E7" + }, + { + "value": "#2a2a4a", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-foreground": { + "type": "color", + "value": [ + { + "value": "#37352F" + }, + { + "value": "#e0e0e0", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-primary": { + "type": "color", + "value": [ + { + "value": "#2383E2" + }, + { + "value": "#4a9eff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#155DFF" + }, + { + "value": "#155DFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-primary-foreground": { + "type": "color", + "value": [ + { + "value": "#FFFFFF" + }, + { + "value": "#ffffff", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--sidebar-ring": { + "type": "color", + "value": [ + { + "value": "#2383E2" + }, + { + "value": "#4a9eff", + "theme": { + "Mode": "Dark" + } + }, + { + "value": "#155DFF" + }, + { + "value": "#155DFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--white": { + "type": "color", + "value": "#ffffff" + }, + "--accent-yellow": { + "type": "color", + "value": [ + { + "value": "#F0B100" + }, + { + "value": "#F0B100", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-blue-light": { + "type": "color", + "value": [ + { + "value": "#155DFF14" + }, + { + "value": "#155DFF20", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-green-light": { + "type": "color", + "value": [ + { + "value": "#00B38B14" + }, + { + "value": "#00B38B20", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-purple-light": { + "type": "color", + "value": [ + { + "value": "#A932FF14" + }, + { + "value": "#A932FF20", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-red-light": { + "type": "color", + "value": [ + { + "value": "#E03E3E14" + }, + { + "value": "#f4433620", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--accent-yellow-light": { + "type": "color", + "value": [ + { + "value": "#F0B10014" + }, + { + "value": "#F0B10020", + "theme": { + "Mode": "Dark" + } + } + ] + } + }, + "children": [ + { + "type": "frame", + "id": "rnt0", + "name": "Rename Tab — Normal tab state", + "width": 800, + "height": 120, + "layout": "vertical", + "fill": "$--sidebar", + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "text", + "id": "rnt1", + "name": "frameLabel", + "content": "Normal state: tabs show note title. Double-click to rename.", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal", + "fill": "$--muted-foreground", + "padding": [ + 8, + 12 + ] + }, + { + "type": "frame", + "id": "rnt2", + "name": "Tab Bar Normal", + "width": "fill_container", + "height": 45, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rnt3", + "name": "Tab Active", + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "$--border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rnt4", + "fill": "$--foreground", + "content": "Weekly Review", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "rnt5", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "rnt6", + "name": "Tab Inactive", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1, + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rnt7", + "fill": "$--muted-foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "rnt8", + "opacity": 0, + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "rnt9", + "name": "tabSpacer", + "width": "fill_container", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + } + }, + { + "type": "frame", + "id": "rnta", + "name": "tabControls", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1, + "left": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_around", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rntb", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "text", + "id": "rntc", + "name": "interactionNote", + "content": "Double-click tab title → enters edit mode", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$--muted-foreground", + "padding": [ + 4, + 12 + ] + } + ] + }, + { + "type": "frame", + "id": "rne0", + "name": "Rename Tab — Double-clicked (editing mode)", + "width": 800, + "height": 120, + "layout": "vertical", + "fill": "$--sidebar", + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "text", + "id": "rne1", + "name": "frameLabel", + "content": "Editing mode: tab title replaced with inline input field, text selected.", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal", + "fill": "$--muted-foreground", + "padding": [ + 8, + 12 + ] + }, + { + "type": "frame", + "id": "rne2", + "name": "Tab Bar Editing", + "width": "fill_container", + "height": 45, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rne3", + "name": "Tab Active (Editing)", + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "$--border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rne4", + "name": "Inline Input", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--ring" + }, + "cornerRadius": 3, + "padding": [ + 2, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rne5", + "fill": "$--foreground", + "content": "Weekly Review", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500", + "highlight": "$--primary", + "highlightOpacity": 0.2 + } + ] + }, + { + "type": "icon_font", + "id": "rne6", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "rne7", + "name": "Tab Inactive", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1, + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rne8", + "fill": "$--muted-foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "rne9", + "opacity": 0, + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "rnea", + "name": "tabSpacer", + "width": "fill_container", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + } + }, + { + "type": "frame", + "id": "rneb", + "name": "tabControls", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1, + "left": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_around", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rnec", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "text", + "id": "rned", + "name": "interactionNote", + "content": "Enter → save & rename file + update wiki links | Escape → cancel | Blur → save", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$--muted-foreground", + "padding": [ + 4, + 12 + ] + } + ] + }, + { + "type": "frame", + "id": "rns0", + "name": "Rename Tab — Saved (updated name)", + "width": 800, + "height": 120, + "layout": "vertical", + "fill": "$--sidebar", + "theme": { + "Mode": "Light" + }, + "children": [ + { + "type": "text", + "id": "rns1", + "name": "frameLabel", + "content": "After save: tab shows updated title. File renamed, wiki links updated across vault.", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "normal", + "fill": "$--muted-foreground", + "padding": [ + 8, + 12 + ] + }, + { + "type": "frame", + "id": "rns2", + "name": "Tab Bar Saved", + "width": "fill_container", + "height": 45, + "fill": "$--sidebar", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rns3", + "name": "Tab Active (Renamed)", + "height": "fill_container", + "fill": "$--background", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + }, + "fill": "$--border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rns4", + "fill": "$--foreground", + "content": "Sprint Retrospective", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "500" + }, + { + "type": "icon_font", + "id": "rns5", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "rns6", + "name": "Tab Inactive", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1, + "bottom": 1 + }, + "fill": "$--sidebar-border" + }, + "gap": 6, + "padding": [ + 0, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "rns7", + "fill": "$--muted-foreground", + "content": "Laputa App", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "rns8", + "opacity": 0, + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "$--muted-foreground" + } + ] + }, + { + "type": "frame", + "id": "rns9", + "name": "tabSpacer", + "width": "fill_container", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + } + }, + { + "type": "frame", + "id": "rnsa", + "name": "tabControls", + "height": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1, + "left": 1 + }, + "fill": "$--border" + }, + "gap": 12, + "padding": [ + 0, + 12 + ], + "justifyContent": "space_around", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rnsb", + "width": 16, + "height": 16, + "iconFontName": "plus", + "iconFontFamily": "phosphor", + "fill": "$--muted-foreground" + } + ] + } + ] + }, + { + "type": "text", + "id": "rnsc", + "name": "interactionNote", + "content": "File: weekly-review.md → sprint-retrospective.md | [[Weekly Review]] → [[Sprint Retrospective]] in all notes", + "fontFamily": "Inter", + "fontSize": 10, + "fontWeight": "normal", + "fill": "$--muted-foreground", + "padding": [ + 4, + 12 + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/e2e/rename-tab.spec.ts b/e2e/rename-tab.spec.ts new file mode 100644 index 00000000..10bcd371 --- /dev/null +++ b/e2e/rename-tab.spec.ts @@ -0,0 +1,80 @@ +import { test, expect } from '@playwright/test' + +test('rename tab by double-clicking', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(2000) + + // Screenshot initial state + await page.screenshot({ path: 'test-results/rename-01-initial.png', fullPage: true }) + + // Click a note in the list using text content + await page.getByText('Deprecated Workflow').first().click() + await page.waitForTimeout(500) + + // Screenshot: note opened as tab + await page.screenshot({ path: 'test-results/rename-02-note-opened.png', fullPage: true }) + + // Find the tab title in the tab bar and double-click it + const tabTitle = page.locator('.group span.truncate').first() + await expect(tabTitle).toBeVisible({ timeout: 5000 }) + const originalTitle = await tabTitle.textContent() + console.log(`Original title: "${originalTitle}"`) + + await tabTitle.dblclick() + await page.waitForTimeout(300) + + // Screenshot: editing mode + await page.screenshot({ path: 'test-results/rename-03-editing.png', fullPage: true }) + + // Verify input appeared + const editInput = page.locator('.group input') + await expect(editInput).toBeVisible({ timeout: 3000 }) + + // Type new name + await editInput.fill('Renamed Test Note') + await page.screenshot({ path: 'test-results/rename-04-typing.png', fullPage: true }) + + // Press Enter to save + await editInput.press('Enter') + await page.waitForTimeout(1000) + + // Screenshot: after rename + await page.screenshot({ path: 'test-results/rename-05-saved.png', fullPage: true }) + + // Verify tab title changed + const newTabTitle = page.locator('.group span.truncate').first() + await expect(newTabTitle).toHaveText('Renamed Test Note') +}) + +test('cancel rename with Escape', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(2000) + + // Open a note + await page.getByText('Deprecated Workflow').first().click() + await page.waitForTimeout(500) + + const tabTitle = page.locator('.group span.truncate').first() + const originalTitle = await tabTitle.textContent() + + // Double-click to edit + await tabTitle.dblclick() + await page.waitForTimeout(300) + + const editInput = page.locator('.group input') + await expect(editInput).toBeVisible({ timeout: 3000 }) + + // Type something different + await editInput.fill('Will Be Cancelled') + + // Press Escape to cancel + await editInput.press('Escape') + await page.waitForTimeout(300) + + // Screenshot: after cancel + await page.screenshot({ path: 'test-results/rename-06-cancelled.png', fullPage: true }) + + // Verify title unchanged + const afterTitle = page.locator('.group span.truncate').first() + await expect(afterTitle).toHaveText(originalTitle!) +}) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4fb24cc8..d4464009 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1904,6 +1904,7 @@ dependencies = [ "futures-util", "gray_matter", "log", + "regex", "reqwest 0.12.28", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index efcb16e1..8e9a7826 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } futures-util = "0.3" base64 = "0.22" +regex = "1" dirs = "5" [dev-dependencies] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index acaaee6d..3d590606 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,7 +5,7 @@ pub mod vault; use ai_chat::{AiChatRequest, AiChatResponse}; use git::{GitCommit, ModifiedFile}; -use vault::VaultEntry; +use vault::{VaultEntry, RenameResult}; use frontmatter::FrontmatterValue; #[tauri::command] @@ -68,6 +68,11 @@ fn save_image(vault_path: String, filename: String, data: String) -> Result Result { + vault::rename_note(&vault_path, &old_path, &new_title) +} + #[tauri::command] fn purge_trash(vault_path: String) -> Result, String> { vault::purge_trash(&vault_path) @@ -108,6 +113,7 @@ pub fn run() { get_note_content, update_frontmatter, delete_frontmatter_property, + rename_note, get_file_history, get_modified_files, get_file_diff, diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index c76245e4..50ecb9b6 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -1,5 +1,6 @@ use gray_matter::engine::YAML; use gray_matter::Matter; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; @@ -802,6 +803,200 @@ pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result String { + title + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-") +} + +/// Update the first H1 heading in markdown content to a new title. +fn update_h1_title(content: &str, new_title: &str) -> String { + let has_h1 = content.lines().any(|l| l.trim().starts_with("# ")); + if !has_h1 { + return content.to_string(); + } + + let result: Vec = content.lines().map(|l| { + if l.trim().starts_with("# ") { format!("# {}", new_title) } else { l.to_string() } + }).collect(); + + let joined = result.join("\n"); + if content.ends_with('\n') && !joined.ends_with('\n') { + format!("{}\n", joined) + } else { + joined + } +} + +/// Build a regex that matches wiki links referencing old title or path stem. +fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option { + let pattern_str = format!( + r"\[\[(?:{}|{})(\|[^\]]*?)?\]\]", + regex::escape(old_title), + regex::escape(old_path_stem), + ); + Regex::new(&pattern_str).ok() +} + +/// Check if a path is a vault markdown file eligible for wikilink replacement. +fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool { + path.is_file() + && path != exclude + && path.extension().is_some_and(|ext| ext == "md") +} + +/// Replace wikilink references in a single file's content. Returns updated content if changed. +fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> Option { + if !re.is_match(content) { + return None; + } + let replaced = re.replace_all(content, |caps: ®ex::Captures| { + match caps.get(1) { + Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()), + None => format!("[[{}]]", new_title), + } + }); + if replaced != content { Some(replaced.into_owned()) } else { None } +} + +/// Parameters for a vault-wide wikilink replacement. +struct WikilinkReplacement<'a> { + vault_path: &'a Path, + old_title: &'a str, + new_title: &'a str, + old_path_stem: &'a str, + exclude_path: &'a Path, +} + +/// Collect all .md file paths in vault eligible for wikilink replacement. +fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec { + WalkDir::new(vault_path) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .map(|e| e.into_path()) + .filter(|p| is_replaceable_md_file(p, exclude)) + .collect() +} + +/// Replace wiki link references across all vault markdown files. +fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize { + let re = match build_wikilink_pattern(params.old_title, params.old_path_stem) { + Some(r) => r, + None => return 0, + }; + + let files = collect_md_files(params.vault_path, params.exclude_path); + files.iter().filter(|path| { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return false, + }; + match replace_wikilinks_in_content(&content, &re, params.new_title) { + Some(new_content) => fs::write(path, &new_content).is_ok(), + None => false, + } + }).count() +} + +/// Check if frontmatter contains a `title:` key. +fn frontmatter_has_title_key(content: &str) -> bool { + if !content.starts_with("---\n") { + return false; + } + content[4..].split("\n---").next() + .map(|fm| fm.lines().any(|l| { + let t = l.trim_start(); + t.starts_with("title:") || t.starts_with("\"title\":") + })) + .unwrap_or(false) +} + +/// Update H1 and optionally the `title:` frontmatter field in content. +fn update_note_title_in_content(content: &str, new_title: &str) -> String { + let mut updated = update_h1_title(content, new_title); + if frontmatter_has_title_key(content) { + let value = crate::frontmatter::FrontmatterValue::String(new_title.to_string()); + if let Ok(c) = update_frontmatter_content(&updated, "title", Some(value)) { + updated = c; + } + } + updated +} + +/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review"). +fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str { + abs_path + .strip_prefix(vault_prefix) + .unwrap_or(abs_path) + .strip_suffix(".md") + .unwrap_or(abs_path) +} + +/// Rename a note: update its title, rename the file, and update wiki links across the vault. +pub fn rename_note(vault_path: &str, old_path: &str, new_title: &str) -> Result { + let vault = Path::new(vault_path); + let old_file = Path::new(old_path); + + if !old_file.exists() { + return Err(format!("File does not exist: {}", old_path)); + } + let new_title = new_title.trim(); + if new_title.is_empty() { + return Err("New title cannot be empty".to_string()); + } + + let content = fs::read_to_string(old_file) + .map_err(|e| format!("Failed to read {}: {}", old_path, e))?; + let old_filename = old_file.file_name() + .map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); + let old_title = extract_title(&content, &old_filename); + + if old_title == new_title { + return Ok(RenameResult { new_path: old_path.to_string(), updated_files: 0 }); + } + + // Update content (H1 + frontmatter title) + let updated_content = update_note_title_in_content(&content, new_title); + + // Compute new path and write file + let parent_dir = old_file.parent().ok_or("Cannot determine parent directory")?; + let new_file = parent_dir.join(format!("{}.md", title_to_slug(new_title))); + let new_path_str = new_file.to_string_lossy().to_string(); + + fs::write(&new_file, &updated_content) + .map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?; + if old_file != new_file { + fs::remove_file(old_file) + .map_err(|e| format!("Failed to remove old file {}: {}", old_path, e))?; + } + + // Update wikilinks across the vault + let vault_prefix = format!("{}/", vault.to_string_lossy()); + let old_path_stem = to_path_stem(old_path, &vault_prefix); + let updated_files = update_wikilinks_in_vault(&WikilinkReplacement { + vault_path: vault, old_title: &old_title, new_title, old_path_stem, exclude_path: &new_file, + }); + + Ok(RenameResult { new_path: new_path_str, updated_files }) +} + #[cfg(test)] mod tests { use super::*; @@ -1785,4 +1980,137 @@ References: assert_eq!(deleted.len(), 1); assert!(deleted[0].contains("old.md")); } + + // --- rename_note tests --- + + #[test] + fn test_title_to_slug() { + assert_eq!(title_to_slug("Weekly Review"), "weekly-review"); + assert_eq!(title_to_slug("My Note! "), "my-note"); + assert_eq!(title_to_slug("Hello World"), "hello-world"); + } + + #[test] + fn test_update_h1_title() { + let content = "---\nIs A: Note\n---\n# Old Title\n\nContent here.\n"; + let updated = update_h1_title(content, "New Title"); + assert!(updated.contains("# New Title")); + assert!(!updated.contains("# Old Title")); + assert!(updated.contains("Content here.")); + } + + #[test] + fn test_rename_note_basic() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/weekly-review.md", "---\nIs A: Note\n---\n# Weekly Review\n\nContent here.\n"); + + let old_path = vault.join("note/weekly-review.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "Sprint Retrospective", + ).unwrap(); + + assert!(result.new_path.ends_with("sprint-retrospective.md")); + assert!(!old_path.exists()); + assert!(Path::new(&result.new_path).exists()); + + let new_content = fs::read_to_string(&result.new_path).unwrap(); + assert!(new_content.contains("# Sprint Retrospective")); + } + + #[test] + fn test_rename_note_updates_wikilinks() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/weekly-review.md", "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n"); + create_test_file(vault, "note/other.md", "---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n"); + create_test_file(vault, "project/my-project.md", "---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n"); + + let old_path = vault.join("note/weekly-review.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "Sprint Retrospective", + ).unwrap(); + + assert_eq!(result.updated_files, 2); + + let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap(); + assert!(other_content.contains("[[Sprint Retrospective]]")); + assert!(!other_content.contains("[[Weekly Review]]")); + + let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap(); + assert!(project_content.contains("[[Sprint Retrospective]]")); + } + + #[test] + fn test_rename_note_same_title_noop() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n"); + + let old_path = vault.join("note/my-note.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "My Note", + ).unwrap(); + + assert_eq!(result.new_path, old_path.to_str().unwrap()); + assert_eq!(result.updated_files, 0); + } + + #[test] + fn test_rename_note_empty_title_error() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/test.md", "# Test\n"); + + let old_path = vault.join("note/test.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + " ", + ); + assert!(result.is_err()); + } + + #[test] + fn test_rename_note_preserves_pipe_alias_in_wikilinks() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n"); + create_test_file(vault, "note/ref.md", "# Ref\n\nSee [[Weekly Review|my review]] for info.\n"); + + let old_path = vault.join("note/weekly-review.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "Sprint Retro", + ).unwrap(); + + assert_eq!(result.updated_files, 1); + let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap(); + assert!(ref_content.contains("[[Sprint Retro|my review]]")); + } + + #[test] + fn test_rename_note_updates_title_frontmatter() { + let dir = TempDir::new().unwrap(); + let vault = dir.path(); + create_test_file(vault, "note/old.md", "---\ntitle: Old Name\nIs A: Note\n---\n# Old Name\n"); + + let old_path = vault.join("note/old.md"); + let result = rename_note( + vault.to_str().unwrap(), + old_path.to_str().unwrap(), + "New Name", + ).unwrap(); + + let content = fs::read_to_string(&result.new_path).unwrap(); + assert!(content.contains("title: New Name")); + assert!(content.contains("# New Name")); + } } diff --git a/src/App.tsx b/src/App.tsx index 254f1671..ad30a740 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -96,6 +96,10 @@ function App() { setToastMessage(`Type "${name}" created`) }, [notes]) + const handleRenameTab = useCallback((path: string, newTitle: string) => { + notes.handleRenameNote(path, newTitle, vaultPath, vault.replaceEntry) + }, [notes, vaultPath, vault]) + useAppKeyboard({ onQuickOpen: () => setShowQuickOpen(true), onCreateNote: handleCreateNoteImmediate, @@ -173,6 +177,7 @@ function App() { onRestoreNote={entryActions.handleRestoreNote} onArchiveNote={entryActions.handleArchiveNote} onUnarchiveNote={entryActions.handleUnarchiveNote} + onRenameTab={handleRenameTab} /> diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 503f822f..8dcf883a 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -54,6 +54,7 @@ interface EditorProps { onRestoreNote?: (path: string) => void onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void + onRenameTab?: (path: string, newTitle: string) => void } // --- Custom Inline Content: WikiLink --- @@ -199,6 +200,7 @@ export const Editor = memo(function Editor({ vaultPath, onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote, + onRenameTab, }: EditorProps) { const [diffMode, setDiffMode] = useState(false) const [diffContent, setDiffContent] = useState(null) @@ -419,6 +421,7 @@ export const Editor = memo(function Editor({ onCloseTab={onCloseTab} onCreateNote={onCreateNote} onReorderTabs={onReorderTabs} + onRenameTab={onRenameTab} /> ) diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index 7ee20cca..b8772b44 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useRef, useCallback } from 'react' +import { memo, useState, useRef, useCallback, useEffect } from 'react' import type { VaultEntry } from '../types' import { cn } from '@/lib/utils' import { X } from 'lucide-react' @@ -16,10 +16,73 @@ interface TabBarProps { onCloseTab: (path: string) => void onCreateNote?: () => void onReorderTabs?: (fromIndex: number, toIndex: number) => void + onRenameTab?: (path: string, newTitle: string) => void } const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const +// --- Inline edit --- + +/** Inline edit input shown when user double-clicks a tab title. */ +function InlineTabEdit({ initialValue, onSave, onCancel }: { + initialValue: string + onSave: (value: string) => void + onCancel: () => void +}) { + const [value, setValue] = useState(initialValue) + const inputRef = useRef(null) + // Guard against double-fire: Enter calls handleSave, then React unmounts + // the input (editingPath → null), which triggers blur → handleSave again. + const committedRef = useRef(false) + + useEffect(() => { + inputRef.current?.select() + }, []) + + const handleSave = useCallback(() => { + if (committedRef.current) return + committedRef.current = true + const trimmed = value.trim() + if (trimmed && trimmed !== initialValue) { + onSave(trimmed) + } else { + onCancel() + } + }, [value, initialValue, onSave, onCancel]) + + return ( + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave() + if (e.key === 'Escape') onCancel() + e.stopPropagation() + }} + onBlur={handleSave} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + draggable={false} + onDragStart={(e) => e.preventDefault()} + style={{ + width: '100%', + minWidth: 40, + maxWidth: 150, + background: 'var(--background)', + border: '1px solid var(--ring)', + borderRadius: 3, + padding: '2px 6px', + fontSize: 12, + fontWeight: 500, + color: 'var(--foreground)', + outline: 'none', + fontFamily: 'inherit', + }} + /> + ) +} + // --- Drag-and-drop helpers --- function computeDropTarget(dragIdx: number | null, dropIdx: number | null): number | null { @@ -88,19 +151,23 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) { ) } -function TabItem({ tab, isActive, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, dragProps }: { +function TabItem({ tab, isActive, isEditing, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: { tab: Tab isActive: boolean + isEditing: boolean isDragging: boolean showDropBefore: boolean showDropAfter: boolean onSwitch: () => void onClose: () => void + onDoubleClick: () => void + onRenameSave: (newTitle: string) => void + onRenameCancel: () => void dragProps: React.HTMLAttributes }) { return (
!isEditing && onSwitch()} > {showDropBefore && } - {tab.entry.title} + {isEditing ? ( + + ) : ( + { e.stopPropagation(); onDoubleClick() }}> + {tab.entry.title} + + )}