fix: show new (untracked) notes in Changes view (#161)

* fix: show new (untracked) notes in Changes view

Two fixes:
1. Use `git status --porcelain --untracked-files=all` so individual
   untracked files in subdirectories are listed (instead of just the
   directory name, which gets filtered out by the .md extension check).
2. Call loadModifiedFiles after persistOptimistic completes, so notes
   created via handleCreateNote/handleCreateType appear in Changes
   immediately without requiring a manual Cmd+S.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: trigger CI for PR #161

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Luca Rossi
2026-03-02 03:07:02 +01:00
committed by GitHub
parent 8e2ca1e3a1
commit 9ec0e8a3a1
5 changed files with 90 additions and 6 deletions

View File

@@ -128,7 +128,7 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
let vault = Path::new(vault_path);
let output = Command::new("git")
.args(["status", "--porcelain"])
.args(["status", "--porcelain", "--untracked-files=all"])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {}", e))?;
@@ -740,7 +740,42 @@ mod tests {
assert!(modified.len() >= 2);
let statuses: Vec<&str> = modified.iter().map(|f| f.status.as_str()).collect();
assert!(statuses.contains(&"modified") || statuses.contains(&"untracked"));
assert!(statuses.contains(&"modified"));
assert!(statuses.contains(&"untracked"));
}
#[test]
fn test_get_modified_files_untracked_in_subdirectory() {
let dir = setup_git_repo();
let vault = dir.path();
// Create initial commit so git is initialized
fs::write(vault.join("init.md"), "# Init\n").unwrap();
Command::new("git")
.args(["add", "init.md"])
.current_dir(vault)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "Initial"])
.current_dir(vault)
.output()
.unwrap();
// Create a new untracked file in a subdirectory (simulates new note creation)
fs::create_dir_all(vault.join("note")).unwrap();
fs::write(vault.join("note/brand-new.md"), "# Brand New\n").unwrap();
let modified = get_modified_files(vault.to_str().unwrap()).unwrap();
assert_eq!(modified.len(), 1);
assert_eq!(modified[0].status, "untracked");
assert_eq!(modified[0].relative_path, "note/brand-new.md");
assert!(
modified[0].path.ends_with("/note/brand-new.md"),
"Full path should end with relative path: {}",
modified[0].path
);
}
#[test]

View File

@@ -142,7 +142,7 @@ function App() {
// Read at callback time, so it's always current when user presses Cmd+N.
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content) })
const notes = useNoteActions({ addEntry: vault.addEntry, removeEntry: vault.removeEntry, updateContent: vault.updateContent, entries: vault.entries, setToastMessage, updateEntry: vault.updateEntry, addPendingSave: vault.addPendingSave, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths, markContentPending: (path, content) => contentChangeRef.current(path, content), onNewNotePersisted: vault.loadModifiedFiles })
const navHistory = useNavigationHistory()

View File

@@ -985,6 +985,19 @@ describe('NoteList — virtual list with large datasets', () => {
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
expect(screen.queryByText('Matteo Cellini')).not.toBeInTheDocument()
})
it('shows untracked (new) notes alongside modified notes in changes view', () => {
const mixedFiles = [
{ path: mockEntries[0].path, relativePath: 'project/26q1-laputa-app.md', status: 'modified' as const },
{ path: mockEntries[2].path, relativePath: 'person/matteo-cellini.md', status: 'untracked' as const },
]
render(
<NoteList entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={mixedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
})
})
})

View File

@@ -535,6 +535,38 @@ describe('useNoteActions hook', () => {
expect(trackUnsaved).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'))
expect(markContentPending).toHaveBeenCalledWith(expect.stringContaining('note/untitled-note.md'), expect.stringContaining('Untitled note'))
})
it('calls onNewNotePersisted after successful disk write (non-Tauri)', async () => {
const onNewNotePersisted = vi.fn()
const config = makeConfig()
config.onNewNotePersisted = onNewNotePersisted
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Persist Callback', 'Note')
await new Promise((r) => setTimeout(r, 0))
})
expect(onNewNotePersisted).toHaveBeenCalledTimes(1)
})
it('does not call onNewNotePersisted when disk write fails (Tauri)', async () => {
vi.mocked(isTauri).mockReturnValue(true)
vi.mocked(invoke).mockRejectedValueOnce(new Error('disk full'))
const onNewNotePersisted = vi.fn()
const config = makeConfig()
config.onNewNotePersisted = onNewNotePersisted
const { result } = renderHook(() => useNoteActions(config))
await act(async () => {
result.current.handleCreateNote('Fail Persist', 'Note')
await new Promise((r) => setTimeout(r, 0))
})
expect(onNewNotePersisted).not.toHaveBeenCalled()
})
})
describe('optimistic error recovery (Tauri mode)', () => {

View File

@@ -33,6 +33,8 @@ export interface NoteActionsConfig {
unsavedPaths?: Set<string>
/** Called when an unsaved note is created so the save system can buffer its initial content. */
markContentPending?: (path: string, content: string) => void
/** Called after a new note is persisted to disk (e.g. to refresh git status for Changes view). */
onNewNotePersisted?: () => void
}
async function performRename(
@@ -199,13 +201,14 @@ interface PersistCallbacks {
onFail: (p: string) => void
onStart?: (p: string) => void
onEnd?: (p: string) => void
onPersisted?: () => void
}
/** Persist to disk; track pending state via onStart/onEnd; revert on failure. */
function persistOptimistic(path: string, content: string, cbs: PersistCallbacks): void {
cbs.onStart?.(path)
persistNewNote(path, content)
.then(() => cbs.onEnd?.(path))
.then(() => { cbs.onEnd?.(path); cbs.onPersisted?.() })
.catch(() => { cbs.onEnd?.(path); cbs.onFail(path) })
}
@@ -296,13 +299,14 @@ export function useNoteActions(config: NoteActionsConfig) {
onFail: revertOptimisticNote,
onStart: addPendingSave,
onEnd: removePendingSave,
onPersisted: config.onNewNotePersisted,
}
const pendingNamesRef = useRef<Set<string>>(new Set())
const handleCreateNote = useCallback((title: string, type: string) => {
createAndPersist(resolveNewNote(title, type), addEntry, openTabWithContent, persistCbs)
}, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
}, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave, config.onNewNotePersisted]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
const handleCreateNoteImmediate = useCallback((type?: string) => {
const noteType = type || 'Note'
@@ -331,7 +335,7 @@ export function useNoteActions(config: NoteActionsConfig) {
const handleCreateType = useCallback((typeName: string) => {
createAndPersist(resolveNewType(typeName), addEntry, openTabWithContent, persistCbs)
}, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
}, [openTabWithContent, addEntry, revertOptimisticNote, addPendingSave, removePendingSave, config.onNewNotePersisted]) // eslint-disable-line react-hooks/exhaustive-deps -- persistCbs is stable when deps are
const fmCallbacks = { updateTab: updateTabContent, updateEntry, toast: setToastMessage }