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:
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user