Compare commits

...

6 Commits

Author SHA1 Message Date
lucaronin
c24f60c594 fix: restore breadcrumb action icon sizing 2026-04-10 21:22:27 +02:00
lucaronin
71a3be577d fix: always show breadcrumb filename 2026-04-10 21:08:45 +02:00
lucaronin
717fa9d1a6 fix: prefer note icons in favorite rows 2026-04-10 20:58:18 +02:00
lucaronin
78e76e0d54 fix: defer native menu shortcuts in tauri 2026-04-10 20:43:36 +02:00
lucaronin
b01c6b4a4b fix: format rename rust helpers 2026-04-10 20:25:19 +02:00
lucaronin
db4359981f fix: update rename filename wikilinks 2026-04-10 20:20:08 +02:00
10 changed files with 266 additions and 65 deletions

View File

@@ -100,11 +100,7 @@ fn update_wikilinks_in_vault(
Some(r) => r,
None => return 0,
};
replace_wikilinks_in_files(
collect_md_files(vault_path, exclude_path),
&re,
new_target,
)
replace_wikilinks_in_files(collect_md_files(vault_path, exclude_path), &re, new_target)
}
fn replace_wikilinks_in_files(
@@ -621,7 +617,11 @@ mod tests {
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]] for info.\n");
create_test_file(
vault,
"note/ref.md",
"# Ref\n\nSee [[weekly-review]] for info.\n",
);
let old_path = vault.join("note/weekly-review.md");
let result = rename_note(

View File

@@ -0,0 +1,70 @@
import { readFileSync } from 'node:fs'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { BreadcrumbBar } from './BreadcrumbBar'
import './Editor.css'
import type { VaultEntry } from '../types'
const baseEntry: VaultEntry = {
path: '/vault/note/test.md',
filename: 'test.md',
title: 'Test Note',
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 100,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
template: null,
sort: null,
sidebarLabel: null,
view: null,
visible: null,
properties: {},
organized: false,
favorite: false,
favoriteIndex: null,
listPropertiesDisplay: [],
hasH1: false,
}
const defaultProps = {
wordCount: 100,
showDiffToggle: false,
diffMode: false,
diffLoading: false,
onToggleDiff: vi.fn(),
}
describe('BreadcrumbBar filename visibility', () => {
it('keeps the filename visible in the breadcrumb by default', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
expect(screen.getByText('test')).toBeVisible()
})
it('keeps the filename visible even when the bar is marked as title-hidden', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
container.querySelector('.breadcrumb-bar')?.setAttribute('data-title-hidden', '')
expect(screen.getByText('test')).toBeVisible()
})
it('keeps breadcrumb action buttons on the zero-padding footprint', () => {
const editorCss = readFileSync(`${process.cwd()}/src/components/Editor.css`, 'utf8')
expect(editorCss).toContain(".breadcrumb-bar__actions [data-slot='button']")
expect(editorCss).toContain('width: auto;')
expect(editorCss).toContain('height: auto;')
expect(editorCss).toContain('padding: 0;')
expect(editorCss).toContain('border-radius: 0;')
})
})

View File

@@ -22,7 +22,8 @@
opacity: 0.55;
}
/* Breadcrumb bar: title + border toggled via data attribute (no React re-render) */
/* Breadcrumb bar: border can still react to the data attribute, but the
breadcrumb filename/title stays visible at all times. */
.breadcrumb-bar {
transition: border-color 0.2s ease;
}
@@ -32,11 +33,19 @@
}
.breadcrumb-bar__title {
display: none;
display: flex;
}
.breadcrumb-bar[data-title-hidden] .breadcrumb-bar__title {
display: flex;
.breadcrumb-bar__actions [data-slot='button'] {
width: auto;
height: auto;
min-width: 0;
padding: 0;
border-radius: 0;
}
.breadcrumb-bar__actions [data-slot='button']:hover {
background: transparent;
}
/* Scroll area wrapping title + editor — single scroll context for alignment */

View File

@@ -900,6 +900,46 @@ describe('Sidebar', () => {
favorite: true, favoriteIndex: 0,
}
function getFavoriteAndTypeRows(favoriteTitle = 'My Favorite Note') {
const favoriteLabel = screen.getByText(favoriteTitle)
const favoriteRow = favoriteLabel.closest('.cursor-pointer')
const typeLabel = screen.getByText('Projects')
const typeRow = typeLabel.closest('.cursor-pointer')
expect(favoriteRow).not.toBeNull()
expect(typeRow).not.toBeNull()
return {
favoriteLabel,
favoriteRow: favoriteRow as HTMLElement,
typeLabel,
typeRow: typeRow as HTMLElement,
}
}
function expectFavoriteIconToMatchTypeSizing(favoriteRow: HTMLElement) {
const favoriteIcon = favoriteRow.querySelector('svg')
expect(favoriteIcon).not.toBeNull()
expect(favoriteIcon!.getAttribute('width')).toBe('16')
expect(favoriteIcon!.getAttribute('height')).toBe('16')
expect(favoriteIcon!.getAttribute('style')).toContain('var(--accent-red)')
return favoriteIcon as SVGElement
}
function expectFavoriteRowToMatchTypeRow(favoriteTitle = 'My Favorite Note') {
const { favoriteLabel, favoriteRow, typeLabel, typeRow } = getFavoriteAndTypeRows(favoriteTitle)
expect(favoriteRow.className).toBe(typeRow.className)
expect(favoriteRow.style.padding).toBe(typeRow.style.padding)
expect(favoriteRow.style.gap).toBe(typeRow.style.gap)
expect(favoriteLabel.className).toContain(typeLabel.className)
expect(favoriteLabel.className).toContain('truncate')
return {
favoriteRow,
typeRow,
favoriteIcon: expectFavoriteIconToMatchTypeSizing(favoriteRow),
}
}
it('shows FAVORITES section when there are favorites', () => {
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('FAVORITES')).toBeInTheDocument()
@@ -920,21 +960,28 @@ describe('Sidebar', () => {
it('matches the Types row styling and type color for favorites', () => {
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
expectFavoriteRowToMatchTypeRow()
})
const favoriteLabel = screen.getByText('My Favorite Note')
const favoriteRow = favoriteLabel.closest('.cursor-pointer')
const typeLabel = screen.getByText('Projects')
const typeRow = typeLabel.closest('.cursor-pointer')
const favoriteIcon = favoriteRow?.querySelector('svg')
it('prefers a favorite note emoji icon over the type icon fallback', () => {
const emojiFavorite = { ...favEntry, title: 'Emoji Favorite', icon: '🚀' }
expect(favoriteRow?.className).toBe(typeRow?.className)
expect(favoriteRow?.style.padding).toBe(typeRow?.style.padding)
expect(favoriteRow?.style.gap).toBe(typeRow?.style.gap)
expect(favoriteLabel.className).toContain(typeLabel.className)
expect(favoriteLabel.className).toContain('truncate')
expect(favoriteIcon?.getAttribute('width')).toBe('16')
expect(favoriteIcon?.getAttribute('height')).toBe('16')
expect(favoriteIcon?.getAttribute('style')).toContain('var(--accent-red)')
render(<Sidebar entries={[...mockEntries, emojiFavorite]} selection={defaultSelection} onSelect={() => {}} />)
const emojiRow = screen.getByText('Emoji Favorite').closest('.cursor-pointer')
expect(within(emojiRow as HTMLElement).getByText('🚀')).toBeInTheDocument()
expect(emojiRow?.querySelector('svg')).toBeNull()
})
it('uses a favorite note phosphor icon and keeps the type color', () => {
const iconFavorite = { ...favEntry, title: 'Icon Favorite', icon: 'rocket' }
render(<Sidebar entries={[...mockEntries, iconFavorite]} selection={defaultSelection} onSelect={() => {}} />)
const { favoriteIcon, typeRow } = expectFavoriteRowToMatchTypeRow('Icon Favorite')
const typeIcon = typeRow.querySelector('svg')
expect(typeIcon).not.toBeNull()
expect(favoriteIcon.innerHTML).not.toBe(typeIcon!.innerHTML)
})
it('falls back to a neutral icon color when the favorite type has no defined color', () => {

View File

@@ -360,7 +360,7 @@ const FAVORITE_TYPE_ICON_MAP: Record<string, string> = {
function getFavoriteIcon(entry: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
return typeEntry?.icon ?? FAVORITE_TYPE_ICON_MAP[entry.isA ?? ''] ?? 'file-text'
return entry.icon ?? typeEntry?.icon ?? FAVORITE_TYPE_ICON_MAP[entry.isA ?? ''] ?? 'file-text'
}
function SortableFavoriteItem({

View File

@@ -1,6 +1,7 @@
import type { MutableRefObject } from 'react'
import type { ViewMode } from './useViewMode'
import { trackEvent } from '../lib/telemetry'
import { isTauri } from '../mock-tauri'
export interface KeyboardActions {
onQuickOpen: () => void
@@ -29,8 +30,13 @@ export interface KeyboardActions {
type ShortcutHandler = () => void
type ShortcutMap = Record<string, ShortcutHandler>
type NativeMenuCombo = 'command' | 'command-shift'
const TEXT_EDITING_KEYS = new Set(['Backspace', 'Delete'])
const TAURI_NATIVE_MENU_KEYS: Record<NativeMenuCombo, Set<string>> = {
command: new Set(['1', '2', '3', 'n', 'j', 'p', 's', 'k', '=', '+', '-', '0', '[', ']', '\\', 'Backspace', 'Delete']),
'command-shift': new Set(['f', 'i', 'o', 'l']),
}
const VIEW_MODE_KEYS: Record<string, ViewMode> = {
'1': 'editor-only',
@@ -57,6 +63,20 @@ function isCommandShiftOnly(e: KeyboardEvent): boolean {
return e.metaKey && e.ctrlKey === false && e.altKey === false && e.shiftKey
}
function nativeMenuComboForEvent(e: KeyboardEvent): NativeMenuCombo | null {
if (isCommandShiftOnly(e)) return 'command-shift'
if (isCommandOrCtrlOnly(e) && e.shiftKey === false) return 'command'
return null
}
function shouldDeferToNativeMenu(e: KeyboardEvent): boolean {
if (!isTauri()) return false
const combo = nativeMenuComboForEvent(e)
if (combo === null) return false
const normalizedKey = combo === 'command-shift' ? e.key.toLowerCase() : e.key
return TAURI_NATIVE_MENU_KEYS[combo].has(normalizedKey)
}
function withActiveTab(
activeTabPathRef: MutableRefObject<string | null>,
handler: (path: string) => void,
@@ -139,6 +159,7 @@ export function handleShiftCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): bo
}
export function handleAppKeyboardEvent(actions: KeyboardActions, event: KeyboardEvent) {
if (shouldDeferToNativeMenu(event)) return
if (handleAiPanelKey(event, actions.onToggleAIChat)) return
const shiftKeyMap = createShiftCommandKeyMap(actions)
if (handleShiftCommandKey(event, shiftKeyMap)) return

View File

@@ -48,7 +48,10 @@ function makeActions() {
}
describe('useAppKeyboard', () => {
afterEach(() => vi.restoreAllMocks())
afterEach(() => {
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
vi.restoreAllMocks()
})
it('Cmd+1 sets view mode to editor-only', () => {
const actions = makeActions()
@@ -92,6 +95,14 @@ describe('useAppKeyboard', () => {
expect(actions.onCreateNote).toHaveBeenCalled()
})
it('Cmd+N defers to native menu in Tauri', () => {
const actions = makeActions()
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
renderHook(() => useAppKeyboard(actions))
fireKey('n', { metaKey: true })
expect(actions.onCreateNote).not.toHaveBeenCalled()
})
it('Cmd+D triggers toggle favorite on active note', () => {
const actions = makeActions()
actions.onToggleFavorite = vi.fn()
@@ -100,6 +111,15 @@ describe('useAppKeyboard', () => {
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
})
it('Cmd+D still uses JS shortcut in Tauri when no native menu owns it', () => {
const actions = makeActions()
actions.onToggleFavorite = vi.fn()
;(window as typeof window & { __TAURI__?: object }).__TAURI__ = {}
renderHook(() => useAppKeyboard(actions))
fireKey('d', { metaKey: true })
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
})
it('Cmd+E triggers toggle organized on active note, not archive', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))

View File

@@ -190,6 +190,43 @@ function handleRenameNote(args: { vault_path: string; old_path: string; new_titl
return { new_path: newPath, updated_files: updatedFiles }
}
function handleRenameNoteFilename(args: {
vault_path: string
old_path: string
new_filename_stem: string
}) {
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
const oldTitle = oldEntry?.title ?? ''
const normalizedStem = args.new_filename_stem.trim().replace(/\.md$/, '')
const oldFilename = args.old_path.split('/').pop() ?? ''
const newFilename = `${normalizedStem}.md`
if (!normalizedStem) {
throw new Error('Invalid filename')
}
if (oldFilename === newFilename) {
return { new_path: args.old_path, updated_files: 0 }
}
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
const newPath = `${parentDir}/${newFilename}`
if (newPath !== args.old_path && Object.prototype.hasOwnProperty.call(MOCK_CONTENT, newPath)) {
throw new Error('A note with that name already exists')
}
delete MOCK_CONTENT[args.old_path]
MOCK_CONTENT[newPath] = oldContent
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
syncWindowContent()
return { new_path: newPath, updated_files: updatedFiles }
}
const trimOrNull = (v: string | null | undefined): string | null => v?.trim() || null
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
@@ -275,6 +312,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
rename_note: handleRenameNote,
rename_note_filename: handleRenameNoteFilename,
github_list_repos: () => [
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
{ name: 'laputa-app', full_name: 'lucaong/laputa-app', description: 'Laputa desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/laputa-app.git', html_url: 'https://github.com/lucaong/laputa-app', updated_at: '2026-02-19T15:00:00Z' },

View File

@@ -33,14 +33,14 @@ async function openNote(page: import('@playwright/test').Page, title: string) {
await page.waitForTimeout(300)
}
/** Helper: rename the active note through the stable TitleField. */
async function renameActiveNote(page: import('@playwright/test').Page, nextTitle: string) {
const titleInput = page.getByTestId('title-field-input')
await expect(titleInput).toBeVisible({ timeout: 5_000 })
await titleInput.click()
await titleInput.fill(nextTitle)
await titleInput.press('Enter')
await expect(titleInput).toHaveValue(nextTitle, { timeout: 5_000 })
/** Helper: rename the active note filename through the breadcrumb control. */
async function renameActiveNoteFilename(page: import('@playwright/test').Page, nextFilename: string) {
await page.getByTestId('breadcrumb-filename-trigger').dblclick()
const filenameInput = page.getByTestId('breadcrumb-filename-input')
await expect(filenameInput).toBeVisible({ timeout: 5_000 })
await filenameInput.fill(nextFilename)
await filenameInput.press('Enter')
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(nextFilename, { timeout: 5_000 })
}
// ---------------------------------------------------------------------------
@@ -123,7 +123,7 @@ test('rename note updates filename on disk', async ({ page }) => {
// Open Note B
await openNote(page, 'Note B')
await renameActiveNote(page, 'Note B Renamed')
await renameActiveNoteFilename(page, 'note-b-renamed')
// Verify filesystem: old file gone, new file exists
const oldPath = path.join(tempVaultDir, 'note', 'note-b.md')
@@ -135,7 +135,7 @@ test('rename note updates filename on disk', async ({ page }) => {
}).toPass({ timeout: 5_000 })
const newContent = fs.readFileSync(newPath, 'utf-8')
expect(newContent).toContain('# Note B Renamed')
expect(newContent).toContain('# Note B')
})
// ---------------------------------------------------------------------------
@@ -145,7 +145,7 @@ test('rename note updates filename on disk', async ({ page }) => {
test('rename note updates wikilinks in other files', async ({ page }) => {
// Open Note B and rename it
await openNote(page, 'Note B')
await renameActiveNote(page, 'Note B Updated')
await renameActiveNoteFilename(page, 'note-b-updated')
// Wait for rename to complete (file to be moved)
const newPath = path.join(tempVaultDir, 'note', 'note-b-updated.md')
@@ -153,9 +153,10 @@ test('rename note updates wikilinks in other files', async ({ page }) => {
expect(fs.existsSync(newPath)).toBe(true)
}).toPass({ timeout: 5_000 })
// Verify alpha-project.md now references [[Note B Updated]] instead of [[Note B]]
// Verify alpha-project.md now references the canonical path target.
const alphaContent = fs.readFileSync(path.join(tempVaultDir, 'project', 'alpha-project.md'), 'utf-8')
expect(alphaContent).toContain('[[Note B Updated]]')
expect(alphaContent).toContain('[[note/note-b-updated]]')
expect(alphaContent).not.toContain('[[note/note-b]]')
expect(alphaContent).not.toContain('[[Note B]]')
})

View File

@@ -210,18 +210,30 @@ function readExistingQueryPath(url: URL, res: ServerResponse, key: string): stri
return filePath
}
function updateTitleWikilinks(vaultPath: string, oldTitle: string, newTitle: string, excludePath: string): number {
if (!oldTitle) return 0
function updateTitleWikilinks(vaultPath: string, oldTitle: string, _newTitle: string, excludePath: string): number {
const newPathStem = path.relative(vaultPath, excludePath).replace(/\.md$/i, '')
const oldTargets = collectLegacyWikilinkTargets(oldTitle, excludePath, vaultPath)
return updateWikilinksForTargets(vaultPath, oldTargets, newPathStem, excludePath)
}
function collectLegacyWikilinkTargets(oldTitle: string, oldPath: string, vaultPath: string): string[] {
const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '')
const oldFilenameStem = path.basename(oldPath, '.md')
return [...new Set([oldTitle, oldRelativeStem, oldFilenameStem].filter(Boolean))]
}
function updateWikilinksForTargets(vaultPath: string, oldTargets: string[], newTarget: string, excludePath: string): number {
if (oldTargets.length === 0) return 0
const allFiles = findMarkdownFiles(vaultPath)
const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
const escaped = oldTargets.map(target => target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
const pattern = new RegExp(`\\[\\[(?:${escaped.join('|')})(\\|[^\\]]*?)?\\]\\]`, 'g')
let updatedFiles = 0
for (const filePath of allFiles) {
if (filePath === excludePath) continue
try {
const content = fs.readFileSync(filePath, 'utf-8')
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]`
pipe ? `[[${newTarget}${pipe}]]` : `[[${newTarget}]]`
)
if (replaced !== content) {
fs.writeFileSync(filePath, replaced, 'utf-8')
@@ -234,28 +246,10 @@ function updateTitleWikilinks(vaultPath: string, oldTitle: string, newTitle: str
return updatedFiles
}
function updatePathWikilinks(vaultPath: string, oldPath: string, newPath: string): number {
const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '')
function updatePathWikilinks(vaultPath: string, oldPath: string, newPath: string, oldTitle: string): number {
const newRelativeStem = path.relative(vaultPath, newPath).replace(/\.md$/i, '')
const escaped = oldRelativeStem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
let updatedFiles = 0
for (const filePath of findMarkdownFiles(vaultPath)) {
if (filePath === newPath) continue
try {
const content = fs.readFileSync(filePath, 'utf-8')
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
pipe ? `[[${newRelativeStem}${pipe}]]` : `[[${newRelativeStem}]]`
)
if (replaced !== content) {
fs.writeFileSync(filePath, replaced, 'utf-8')
updatedFiles++
}
} catch {
// Skip unreadable files in the dev vault API.
}
}
return updatedFiles
const oldTargets = collectLegacyWikilinkTargets(oldTitle, oldPath, vaultPath)
return updateWikilinksForTargets(vaultPath, oldTargets, newRelativeStem, newPath)
}
function handleVaultPing(url: URL, res: ServerResponse): boolean {
@@ -388,13 +382,14 @@ async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: Se
}
const newPath = path.join(path.dirname(oldPath), `${trimmed}.md`)
const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md')
if (newPath !== oldPath && fs.existsSync(newPath)) {
sendJson(res, { error: 'A note with that name already exists' }, 409)
return true
}
fs.renameSync(oldPath, newPath)
const updatedFiles = vaultPath ? updatePathWikilinks(vaultPath, oldPath, newPath) : 0
const updatedFiles = vaultPath ? updatePathWikilinks(vaultPath, oldPath, newPath, oldTitle) : 0
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
} catch (err: unknown) {
sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500)