Skip to content

Commit 82e6f8f

Browse files
kelsi-bizerclaude
andauthored
Phase 2b: file tree, navigation, and CRUD (#5)
Removes the hardcoded scratch.md from Phase 2a. The current note now lives in the URL hash so reloads and back/forward preserve state, and a sidebar lets the user browse, create, rename, and delete notes. Sidebar: - Tree built from /api/tree, dirs first then files alphabetically. - Click a file to open it; the row highlights when current. - Hover reveals rename (✎) and delete (✕) buttons. - "Today" button opens daily/YYYY-MM-DD.md, seeding "# YYYY-MM-DD" if missing. - "+ New" button prompts for a path and creates an empty .md file. App: - useHashRoute hook reads/writes location.hash, decoded so paths with slashes round-trip cleanly. - useTree hook exposes { entries, error, reload }; reloaded after every create/rename/delete and after the first save of a new note. - File load handles 404 by seeding (today heading for the daily note, empty otherwise) and queueing a save so the file appears in the tree. - loadedPathRef guards the debounced save against firing for the previous file when the user navigates while typing. - Default landing path is today's daily note. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4ac7886 commit 82e6f8f

7 files changed

Lines changed: 583 additions & 31 deletions

File tree

packages/notes-app/src/App.tsx

Lines changed: 141 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,68 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
22
import { Editor } from './components/Editor';
3-
import { getFile, putFile, FileApiError } from './api/client';
3+
import { Sidebar } from './components/Sidebar';
4+
import { useHashRoute } from './hooks/useHashRoute';
5+
import { useTree } from './hooks/useTree';
46
import { useDebouncedEffect } from './hooks/useDebouncedEffect';
7+
import { buildTree, todayDailyPath, todayHeading } from './utils/tree';
8+
import {
9+
getFile,
10+
putFile,
11+
deleteFile,
12+
moveFile,
13+
fileExists,
14+
FileApiError
15+
} from './api/client';
516

6-
const HARDCODED_PATH = 'scratch.md';
717
const SAVE_DEBOUNCE_MS = 500;
18+
const DEFAULT_NEW_NOTE_PATH = 'pages/untitled.md';
819

920
type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error';
1021

1122
export function App() {
23+
const [path, navigate] = useHashRoute();
24+
const { entries, error: treeError, reload: reloadTree } = useTree();
25+
1226
const [content, setContent] = useState<string>('');
1327
const [loaded, setLoaded] = useState(false);
1428
const [loadError, setLoadError] = useState<string | null>(null);
1529
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
1630
const [saveError, setSaveError] = useState<string | null>(null);
1731

32+
// Track which path the in-memory `content` belongs to. Without this,
33+
// a hash change can fire a stale debounced save against the new path.
34+
const loadedPathRef = useRef<string | null>(null);
35+
36+
// First-load default: navigate to today's daily note.
1837
useEffect(() => {
38+
if (path === '') navigate(todayDailyPath());
39+
// Only on mount.
40+
// eslint-disable-next-line react-hooks/exhaustive-deps
41+
}, []);
42+
43+
// Load the file whenever the path changes.
44+
useEffect(() => {
45+
if (!path) return;
1946
let cancelled = false;
47+
setLoaded(false);
48+
setLoadError(null);
49+
setSaveStatus('idle');
2050
(async () => {
2151
try {
22-
const { content: loadedContent } = await getFile(HARDCODED_PATH);
23-
if (!cancelled) {
24-
setContent(loadedContent);
25-
setLoaded(true);
26-
}
52+
const { content: c } = await getFile(path);
53+
if (cancelled) return;
54+
loadedPathRef.current = path;
55+
setContent(c);
56+
setLoaded(true);
2757
} catch (err) {
2858
if (cancelled) return;
2959
if (err instanceof FileApiError && err.status === 404) {
30-
setContent('# Welcome to BizerOS Knowledge\n\nStart typing — your changes auto-save.\n');
60+
// Missing file: seed with today's heading for daily notes, otherwise empty.
61+
const seed = path === todayDailyPath() ? todayHeading() : '';
62+
loadedPathRef.current = path;
63+
setContent(seed);
3164
setLoaded(true);
65+
if (seed !== '') setSaveStatus('pending');
3266
} else {
3367
setLoadError(err instanceof Error ? err.message : String(err));
3468
}
@@ -37,7 +71,7 @@ export function App() {
3771
return () => {
3872
cancelled = true;
3973
};
40-
}, []);
74+
}, [path]);
4175

4276
const handleChange = useCallback((next: string) => {
4377
setContent(next);
@@ -47,15 +81,18 @@ export function App() {
4781
useDebouncedEffect(
4882
() => {
4983
if (!loaded || saveStatus !== 'pending') return;
84+
const targetPath = loadedPathRef.current;
85+
if (!targetPath || targetPath !== path) return;
5086
let cancelled = false;
87+
const wasNew = entries?.some((e) => e.path === targetPath) === false;
5188
(async () => {
5289
setSaveStatus('saving');
5390
try {
54-
await putFile(HARDCODED_PATH, content);
55-
if (!cancelled) {
56-
setSaveStatus('saved');
57-
setSaveError(null);
58-
}
91+
await putFile(targetPath, content);
92+
if (cancelled) return;
93+
setSaveStatus('saved');
94+
setSaveError(null);
95+
if (wasNew) reloadTree();
5996
} catch (err) {
6097
if (cancelled) return;
6198
setSaveStatus('error');
@@ -66,33 +103,106 @@ export function App() {
66103
cancelled = true;
67104
};
68105
},
69-
[content, loaded, saveStatus],
106+
[content, loaded, saveStatus, path],
70107
SAVE_DEBOUNCE_MS
71108
);
72109

73-
if (loadError) {
74-
return (
75-
<div className="app-error">
76-
<h2>Failed to load {HARDCODED_PATH}</h2>
77-
<pre>{loadError}</pre>
78-
</div>
79-
);
80-
}
110+
const handleNewNote = useCallback(async () => {
111+
const input = window.prompt('New note path (relative to /brain):', DEFAULT_NEW_NOTE_PATH);
112+
if (!input) return;
113+
const target = input.trim().replace(/^\/+/, '');
114+
if (!target) return;
115+
const finalPath = target.endsWith('.md') ? target : `${target}.md`;
116+
try {
117+
const exists = await fileExists(finalPath);
118+
if (!exists) await putFile(finalPath, '');
119+
await reloadTree();
120+
navigate(finalPath);
121+
} catch (err) {
122+
window.alert(`Failed to create: ${err instanceof Error ? err.message : String(err)}`);
123+
}
124+
}, [navigate, reloadTree]);
81125

82-
if (!loaded) {
83-
return <div className="app-loading">Loading…</div>;
84-
}
126+
const handleTodayNote = useCallback(async () => {
127+
const target = todayDailyPath();
128+
try {
129+
const exists = await fileExists(target);
130+
if (!exists) {
131+
await putFile(target, todayHeading());
132+
await reloadTree();
133+
}
134+
navigate(target);
135+
} catch (err) {
136+
window.alert(`Failed to open today's note: ${err instanceof Error ? err.message : String(err)}`);
137+
}
138+
}, [navigate, reloadTree]);
139+
140+
const handleRename = useCallback(
141+
async (oldPath: string) => {
142+
const input = window.prompt(`Rename "${oldPath}" to:`, oldPath);
143+
if (!input) return;
144+
const target = input.trim().replace(/^\/+/, '');
145+
if (!target || target === oldPath) return;
146+
try {
147+
await moveFile(oldPath, target);
148+
await reloadTree();
149+
if (path === oldPath) navigate(target);
150+
} catch (err) {
151+
window.alert(`Rename failed: ${err instanceof Error ? err.message : String(err)}`);
152+
}
153+
},
154+
[navigate, path, reloadTree]
155+
);
156+
157+
const handleDelete = useCallback(
158+
async (target: string) => {
159+
if (!window.confirm(`Delete "${target}"? This cannot be undone.`)) return;
160+
try {
161+
await deleteFile(target);
162+
await reloadTree();
163+
if (path === target) navigate('');
164+
} catch (err) {
165+
window.alert(`Delete failed: ${err instanceof Error ? err.message : String(err)}`);
166+
}
167+
},
168+
[navigate, path, reloadTree]
169+
);
170+
171+
const tree = entries === null ? null : buildTree(entries);
85172

86173
return (
87174
<div className="app">
88175
<header className="app-header">
89176
<span className="app-title">BizerOS Knowledge</span>
90-
<span className="app-path">/brain/{HARDCODED_PATH}</span>
177+
<span className="app-path">/brain/{path || '(no file selected)'}</span>
91178
<SaveIndicator status={saveStatus} error={saveError} />
92179
</header>
93-
<main className="app-main">
94-
<Editor value={content} onChange={handleChange} />
95-
</main>
180+
<div className="app-body">
181+
<Sidebar
182+
tree={tree}
183+
treeError={treeError}
184+
currentPath={path}
185+
onSelect={(p) => navigate(p)}
186+
onNewNote={handleNewNote}
187+
onTodayNote={handleTodayNote}
188+
onRename={handleRename}
189+
onDelete={handleDelete}
190+
/>
191+
<main className="app-main">
192+
{loadError ? (
193+
<div className="app-error">
194+
<h2>Failed to load {path}</h2>
195+
<pre>{loadError}</pre>
196+
</div>
197+
) : !path ? (
198+
<div className="app-empty">Pick a note from the sidebar, or click "Today".</div>
199+
) : !loaded ? (
200+
<div className="app-loading">Loading…</div>
201+
) : (
202+
<Editor value={content} onChange={handleChange} />
203+
)}
204+
</main>
205+
</div>
96206
</div>
97207
);
98208
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { useState } from 'react';
2+
import { TreeNode } from '../utils/tree';
3+
4+
interface FileTreeProps {
5+
nodes: TreeNode[];
6+
currentPath: string;
7+
onSelect: (path: string) => void;
8+
onRename: (path: string) => void;
9+
onDelete: (path: string) => void;
10+
}
11+
12+
export function FileTree({ nodes, currentPath, onSelect, onRename, onDelete }: FileTreeProps) {
13+
return (
14+
<ul className="file-tree">
15+
{nodes.map((node) => (
16+
<FileTreeNode
17+
key={node.path}
18+
node={node}
19+
depth={0}
20+
currentPath={currentPath}
21+
onSelect={onSelect}
22+
onRename={onRename}
23+
onDelete={onDelete}
24+
/>
25+
))}
26+
</ul>
27+
);
28+
}
29+
30+
interface NodeProps {
31+
node: TreeNode;
32+
depth: number;
33+
currentPath: string;
34+
onSelect: (path: string) => void;
35+
onRename: (path: string) => void;
36+
onDelete: (path: string) => void;
37+
}
38+
39+
function FileTreeNode({ node, depth, currentPath, onSelect, onRename, onDelete }: NodeProps) {
40+
const [open, setOpen] = useState(true);
41+
const isCurrent = !node.isDir && node.path === currentPath;
42+
const indent = { paddingLeft: `${depth * 0.75 + 0.5}rem` };
43+
44+
if (node.isDir) {
45+
return (
46+
<li>
47+
<button
48+
className="file-tree-row file-tree-dir"
49+
style={indent}
50+
onClick={() => setOpen((o) => !o)}
51+
>
52+
<span className="file-tree-disclosure">{open ? '▾' : '▸'}</span>
53+
<span className="file-tree-name">{node.name}/</span>
54+
</button>
55+
{open && node.children.length > 0 && (
56+
<ul className="file-tree-children">
57+
{node.children.map((child) => (
58+
<FileTreeNode
59+
key={child.path}
60+
node={child}
61+
depth={depth + 1}
62+
currentPath={currentPath}
63+
onSelect={onSelect}
64+
onRename={onRename}
65+
onDelete={onDelete}
66+
/>
67+
))}
68+
</ul>
69+
)}
70+
</li>
71+
);
72+
}
73+
74+
return (
75+
<li>
76+
<div
77+
className={'file-tree-row file-tree-file' + (isCurrent ? ' file-tree-current' : '')}
78+
style={indent}
79+
>
80+
<button
81+
className="file-tree-name file-tree-button"
82+
onClick={() => onSelect(node.path)}
83+
title={node.path}
84+
>
85+
{node.name}
86+
</button>
87+
<span className="file-tree-actions">
88+
<button
89+
className="file-tree-action"
90+
onClick={() => onRename(node.path)}
91+
title="Rename"
92+
aria-label={`Rename ${node.path}`}
93+
>
94+
95+
</button>
96+
<button
97+
className="file-tree-action"
98+
onClick={() => onDelete(node.path)}
99+
title="Delete"
100+
aria-label={`Delete ${node.path}`}
101+
>
102+
103+
</button>
104+
</span>
105+
</div>
106+
</li>
107+
);
108+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { FileTree } from './FileTree';
2+
import { TreeNode } from '../utils/tree';
3+
4+
interface SidebarProps {
5+
tree: TreeNode[] | null;
6+
treeError: string | null;
7+
currentPath: string;
8+
onSelect: (path: string) => void;
9+
onNewNote: () => void;
10+
onTodayNote: () => void;
11+
onRename: (path: string) => void;
12+
onDelete: (path: string) => void;
13+
}
14+
15+
export function Sidebar({
16+
tree,
17+
treeError,
18+
currentPath,
19+
onSelect,
20+
onNewNote,
21+
onTodayNote,
22+
onRename,
23+
onDelete
24+
}: SidebarProps) {
25+
return (
26+
<aside className="sidebar">
27+
<div className="sidebar-toolbar">
28+
<button onClick={onTodayNote} title="Open today's daily note">
29+
Today
30+
</button>
31+
<button onClick={onNewNote} title="Create a new note">
32+
+ New
33+
</button>
34+
</div>
35+
<div className="sidebar-tree">
36+
{treeError ? (
37+
<div className="sidebar-error">{treeError}</div>
38+
) : tree === null ? (
39+
<div className="sidebar-empty">Loading…</div>
40+
) : tree.length === 0 ? (
41+
<div className="sidebar-empty">Your brain is empty. Create a note to start.</div>
42+
) : (
43+
<FileTree
44+
nodes={tree}
45+
currentPath={currentPath}
46+
onSelect={onSelect}
47+
onRename={onRename}
48+
onDelete={onDelete}
49+
/>
50+
)}
51+
</div>
52+
</aside>
53+
);
54+
}

0 commit comments

Comments
 (0)