Compare commits

...

4 Commits

Author SHA1 Message Date
Test
e47c653b8b feat: add + button to TYPES header for creating new types
Matches the existing pattern in VIEWS and FOLDERS sections. The button
triggers onCreateNewType which opens the CreateTypeDialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:49:50 +02:00
Test
60ecc4dd0e fix: use full vault reload after view create/delete for reliable FOLDERS update
The previous approach used reload_vault_entry + addEntry wrapped in a
try/catch, which silently swallowed failures and left the .yml file
invisible in the FOLDERS view. Replace with reloadVault() which does a
full rescan and is guaranteed to include the new/deleted file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:38:39 +02:00
Test
5323ec6ede fix: rustfmt formatting in discard_file_changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:26:07 +02:00
Test
a007b37089 feat: add per-file discard changes in Changes view
Right-click a file in the Changes view to discard its uncommitted changes.
Shows a confirmation dialog before executing. Handles modified (git checkout),
untracked (delete), and deleted (git restore) files. Includes path traversal
safety checks in the Rust backend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:16:52 +02:00
11 changed files with 363 additions and 24 deletions

View File

@@ -128,6 +128,13 @@ pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, St
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_discard_file(vault_path: String, relative_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
crate::git::discard_file_changes(&vault_path, &relative_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn is_git_repo(vault_path: String) -> bool {
@@ -247,6 +254,12 @@ pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, S
})
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_discard_file(_vault_path: String, _relative_path: String) -> Result<(), String> {
Err("Git discard is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn is_git_repo(_vault_path: String) -> bool {

View File

@@ -21,7 +21,7 @@ pub use remote::{
git_pull, git_push, git_remote_status, has_remote, GitPullResult, GitPushResult,
GitRemoteStatus,
};
pub use status::{get_modified_files, ModifiedFile};
pub use status::{discard_file_changes, get_modified_files, ModifiedFile};
use serde::Serialize;

View File

@@ -63,6 +63,78 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
Ok(files)
}
/// Discard uncommitted changes to a single file.
///
/// - **Modified / Deleted**: `git checkout -- <file>` restores the last committed version.
/// - **Untracked / Added**: the file is removed from disk.
///
/// The `relative_path` must be relative to `vault_path` (the same format
/// returned by [`get_modified_files`]).
pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let abs = vault.join(relative_path);
// Safety: ensure the resolved path stays inside the vault.
// Safety: reject any relative_path that tries to escape the vault via `..`.
for component in std::path::Path::new(relative_path).components() {
if matches!(component, std::path::Component::ParentDir) {
return Err("File path is outside the vault".into());
}
}
if abs.exists() {
let canonical_vault = vault
.canonicalize()
.map_err(|e| format!("Cannot resolve vault path: {e}"))?;
let canonical_file = abs
.canonicalize()
.map_err(|e| format!("Cannot resolve file path: {e}"))?;
if !canonical_file.starts_with(&canonical_vault) {
return Err("File path is outside the vault".into());
}
}
// Determine the file status from `git status --porcelain`.
let output = Command::new("git")
.args(["status", "--porcelain", "--", relative_path])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().find(|l| l.len() >= 4);
let status_code = line.map(|l| l[..2].trim().to_string()).unwrap_or_default();
match status_code.as_str() {
"??" => {
// Untracked — remove from disk.
std::fs::remove_file(&abs)
.map_err(|e| format!("Failed to delete untracked file: {e}"))?;
}
_ => {
// Modified, deleted, added-to-index, renamed, etc. — restore via git.
// Unstage first (ignore errors — file might not be staged).
let _ = Command::new("git")
.args(["reset", "HEAD", "--", relative_path])
.current_dir(vault)
.output();
let checkout = Command::new("git")
.args(["checkout", "--", relative_path])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git checkout: {e}"))?;
if !checkout.status.success() {
let stderr = String::from_utf8_lossy(&checkout.stderr);
return Err(format!("git checkout failed: {}", stderr.trim()));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -172,4 +244,86 @@ mod tests {
after
);
}
#[test]
fn test_discard_modified_file() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Original\n").unwrap();
git_commit(vp, "initial").unwrap();
// Modify the file
fs::write(vault.join("note.md"), "# Changed\n").unwrap();
assert_eq!(get_modified_files(vp).unwrap().len(), 1);
// Discard
discard_file_changes(vp, "note.md").unwrap();
let content = fs::read_to_string(vault.join("note.md")).unwrap();
assert_eq!(content, "# Original\n");
assert!(get_modified_files(vp).unwrap().is_empty());
}
#[test]
fn test_discard_untracked_file() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("init.md"), "# Init\n").unwrap();
git_commit(vp, "initial").unwrap();
// Create an untracked file
fs::write(vault.join("new.md"), "# New\n").unwrap();
assert!(vault.join("new.md").exists());
discard_file_changes(vp, "new.md").unwrap();
assert!(!vault.join("new.md").exists());
assert!(get_modified_files(vp).unwrap().is_empty());
}
#[test]
fn test_discard_deleted_file() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Original\n").unwrap();
git_commit(vp, "initial").unwrap();
// Delete the file
fs::remove_file(vault.join("note.md")).unwrap();
assert!(!vault.join("note.md").exists());
discard_file_changes(vp, "note.md").unwrap();
assert!(vault.join("note.md").exists());
let content = fs::read_to_string(vault.join("note.md")).unwrap();
assert_eq!(content, "# Original\n");
}
#[test]
fn test_discard_rejects_path_outside_vault() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
// Need an initial commit so git status works
fs::write(vault.join("init.md"), "# Init\n").unwrap();
git_commit(vp, "initial").unwrap();
let result = discard_file_changes(vp, "../../../etc/passwd");
assert!(
result.is_err(),
"Should reject path outside vault, got: {:?}",
result
);
assert!(
result.unwrap_err().contains("outside the vault"),
"Error should mention 'outside the vault'"
);
}
}

View File

@@ -140,6 +140,7 @@ pub fn run() {
commands::get_conflict_mode,
commands::git_resolve_conflict,
commands::git_commit_conflict_resolution,
commands::git_discard_file,
commands::is_git_repo,
commands::init_git_repo,
commands::check_claude_cli,

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { VaultEntry } from './types'
import { Sidebar } from './components/Sidebar'
import { NoteList } from './components/NoteList'
import { Editor } from './components/Editor'
@@ -312,6 +311,20 @@ function App() {
openNoteInNewWindow(entry.path, resolvedPath, entry.title)
}, [resolvedPath])
const handleDiscardFile = useCallback(async (relativePath: string) => {
try {
if (isTauri()) {
await invoke('git_discard_file', { vaultPath: resolvedPath, relativePath })
} else {
await mockInvoke('git_discard_file', { vaultPath: resolvedPath, relativePath })
}
await vault.loadModifiedFiles()
await vault.reloadVault()
} catch (err) {
setToastMessage(typeof err === 'string' ? err : 'Failed to discard changes')
}
}, [resolvedPath, vault, setToastMessage])
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
@@ -348,12 +361,7 @@ function App() {
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
// Update vault entries so the .yml file appears in FOLDERS immediately
const filePath = resolvedPath + '/views/' + filename
try {
const entry = await target<VaultEntry>('reload_vault_entry', { path: filePath })
if (editing) { vault.updateEntry(filePath, entry) } else { vault.addEntry(entry) }
} catch { /* non-critical — will appear after next vault reload */ }
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`)
handleSetSelection({ kind: 'view', filename })
@@ -368,8 +376,7 @@ function App() {
const target = isTauri() ? invoke : mockInvoke
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
await vault.reloadViews()
// Remove the .yml file from vault entries so it disappears from FOLDERS immediately
vault.removeEntry(resolvedPath + '/views/' + filename)
await vault.reloadVault()
vault.reloadFolders()
if (selection.kind === 'view' && selection.filename === filename) {
handleSetSelection({ kind: 'filter', filter: 'all' })
@@ -579,7 +586,7 @@ function App() {
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} views={vault.views} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} views={vault.views} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />

View File

@@ -152,7 +152,7 @@ function getFileKindIcon(fileKind: string | undefined): ComponentType<SVGAttribu
return FileText
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote, onPrefetch }: {
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote, onPrefetch, onContextMenu }: {
entry: VaultEntry
isSelected: boolean
isMultiSelected?: boolean
@@ -161,6 +161,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
typeEntryMap: Record<string, VaultEntry>
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
onPrefetch?: (path: string) => void
onContextMenu?: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
const isBinary = entry.fileKind === 'binary'
const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown'
@@ -187,6 +188,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
)}
style={isBinary ? { padding: '14px 16px' } : noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
onClick={handleClick}
onContextMenu={onContextMenu ? (e) => onContextMenu(entry, e) : undefined}
onMouseEnter={!isBinary && onPrefetch ? () => onPrefetch(entry.path) : undefined}
data-testid={isMultiSelected ? 'multi-selected-item' : isBinary ? 'binary-file-item' : undefined}
data-highlighted={isHighlighted || undefined}

View File

@@ -1081,6 +1081,64 @@ describe('NoteList — virtual list with large datasets', () => {
)
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
})
it('shows context menu with "Discard changes" on right-click when onDiscardFile is provided', () => {
const onDiscard = vi.fn()
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
expect(screen.getByTestId('discard-changes-button')).toBeInTheDocument()
})
it('does not show context menu when onDiscardFile is not provided', () => {
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
expect(screen.queryByTestId('changes-context-menu')).not.toBeInTheDocument()
})
it('shows confirmation dialog after clicking "Discard changes"', () => {
const onDiscard = vi.fn()
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
fireEvent.click(screen.getByTestId('discard-changes-button'))
expect(screen.getByTestId('discard-confirm-dialog')).toBeInTheDocument()
// Dialog mentions the note title
const dialog = screen.getByTestId('discard-confirm-dialog')
expect(dialog.textContent).toContain('Build Laputa App')
})
it('calls onDiscardFile with relativePath when discard is confirmed', async () => {
const onDiscard = vi.fn().mockResolvedValue(undefined)
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
fireEvent.click(screen.getByTestId('discard-changes-button'))
fireEvent.click(screen.getByTestId('discard-confirm-button'))
expect(onDiscard).toHaveBeenCalledWith('project/26q1-laputa-app.md')
})
it('does not call onDiscardFile when cancel is clicked', () => {
const onDiscard = vi.fn()
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
fireEvent.click(screen.getByTestId('discard-changes-button'))
fireEvent.click(screen.getByText('Cancel'))
expect(onDiscard).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import { countByFilter, countAllByFilter } from '../utils/noteListHelpers'
@@ -16,6 +16,11 @@ import {
useTypeEntryMap, useNoteListData, useNoteListSearch,
useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState,
} from './note-list/noteListHooks'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface NoteListProps {
entries: VaultEntry[]
@@ -40,10 +45,11 @@ interface NoteListProps {
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onDiscardFile?: (relativePath: string) => Promise<void>
views?: ViewFile[]
}
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, views }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, views }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const isSectionGroup = selection.kind === 'sectionGroup'
@@ -88,9 +94,41 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
// ── Changes view: context menu + discard confirmation ──
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; entry: VaultEntry } | null>(null)
const [discardTarget, setDiscardTarget] = useState<VaultEntry | null>(null)
const ctxMenuRef = useRef<HTMLDivElement>(null)
const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
if (!isChangesView || !onDiscardFile) return
e.preventDefault()
e.stopPropagation()
setCtxMenu({ x: e.clientX, y: e.clientY, entry })
}, [isChangesView, onDiscardFile])
const closeCtxMenu = useCallback(() => setCtxMenu(null), [])
// Close context menu on outside click
useEffect(() => {
if (!ctxMenu) return
const handler = (e: MouseEvent) => {
if (ctxMenuRef.current && !ctxMenuRef.current.contains(e.target as Node)) closeCtxMenu()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [ctxMenu, closeCtxMenu])
const handleDiscardConfirm = useCallback(async () => {
if (!discardTarget || !onDiscardFile) return
const mf = modifiedFiles?.find((f) => f.path === discardTarget.path)
if (!mf) return
await onDiscardFile(mf.relativePath)
setDiscardTarget(null)
}, [discardTarget, onDiscardFile, modifiedFiles])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu])
const handleCreateNote = useCallback(() => {
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
@@ -115,6 +153,35 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
{multiSelect.isMultiSelecting && (
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
)}
{/* Changes view: context menu */}
{ctxMenu && (
<div ref={ctxMenuRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: ctxMenu.x, top: ctxMenu.y, minWidth: 180 }} data-testid="changes-context-menu">
<button
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left text-destructive"
onClick={() => { setDiscardTarget(ctxMenu.entry); closeCtxMenu() }}
data-testid="discard-changes-button"
>
Discard changes
</button>
</div>
)}
{/* Discard confirmation dialog */}
<Dialog open={!!discardTarget} onOpenChange={(open) => { if (!open) setDiscardTarget(null) }}>
<DialogContent showCloseButton={false} data-testid="discard-confirm-dialog">
<DialogHeader>
<DialogTitle>Discard changes</DialogTitle>
<DialogDescription>
Discard changes to <strong>{discardTarget?.title ?? 'this file'}</strong>? This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardTarget(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDiscardConfirm} data-testid="discard-confirm-button">Discard</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1009,4 +1009,29 @@ describe('Sidebar', () => {
expect(onEditView).toHaveBeenCalledWith('active-projects.yml')
})
})
describe('create type button', () => {
it('renders + button in TYPES header when onCreateNewType is provided', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateNewType={() => {}} />
)
expect(screen.getByTestId('create-type-btn')).toBeInTheDocument()
})
it('does not render + button when onCreateNewType is not provided', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />
)
expect(screen.queryByTestId('create-type-btn')).not.toBeInTheDocument()
})
it('calls onCreateNewType when + button is clicked', () => {
const onCreateNewType = vi.fn()
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateNewType={onCreateNewType} />
)
fireEvent.click(screen.getByTestId('create-type-btn'))
expect(onCreateNewType).toHaveBeenCalledOnce()
})
})
})

View File

@@ -334,6 +334,7 @@ export const Sidebar = memo(function Sidebar({
onToggleTypeVisibility, onSelectFavorite, onReorderFavorites,
views = [], onCreateView, onEditView, onDeleteView,
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
onCreateNewType,
}: SidebarProps) {
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
@@ -499,14 +500,24 @@ export const Sidebar = memo(function Sidebar({
{groupCollapsed.sections ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>TYPES</span>
</div>
<span
role="button"
title="Customize sections"
aria-label="Customize sections"
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
>
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
</span>
<div className="flex items-center gap-1.5">
<span
role="button"
title="Customize sections"
aria-label="Customize sections"
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
>
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
</span>
{onCreateNewType && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
data-testid="create-type-btn"
onClick={(e) => { e.stopPropagation(); onCreateNewType() }}
/>
)}
</div>
</button>
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
</div>

View File

@@ -155,6 +155,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
},
get_file_diff: (args: { path: string }) => mockFileDiff(args.path),
get_file_diff_at_commit: (args: { path: string; commitHash: string }) => mockFileDiffAtCommit(args.path, args.commitHash),
git_discard_file: () => {},
git_commit: (args: { message: string }) => {
const count = (mockHasChanges ? mockModifiedFiles().length : 0) + mockSavedSinceCommit.size
mockHasChanges = false