From 91ecd74bc73a75ba0f29f41fa1eec0c0cebf5c58 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Sat, 25 Jul 2026 17:42:51 -0700 Subject: [PATCH 1/2] Guard transcript script calls against a WebView that has gone away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing or unparenting an agent tab unloads its WebView2 and nulls CoreWebView2. AppendRawAsync dereferenced that property with no null check, so a tab closed mid-replay threw a NullReferenceException on the next chunk: swallowed by the catch, so nothing crashed, but it broke into the debugger on every occurrence and abandoned the rest of the transcript replay. AppendRawAsync deliberately cannot use the CanScript guard — it runs while _webViewReady is still false so that live blocks keep queueing behind the restored history — so it now checks _shutDown and the core itself. The other call sites had a subtler version of the same bug: CanScript reads the property, then the call site reads it again after an await, which races the WebView unloading. All of them now capture the core into a local first, and an unrenderable block in AppendHtml is queued rather than dropped. --- .../Controls/ChatTabView.Snapshot.cs | 5 +-- .../Controls/ChatTabView.Transcript.cs | 36 +++++++++++++++---- .../Controls/ChatTabView.xaml.cs | 6 ++-- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs index cbc6fc5..9d0fa2d 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Snapshot.cs @@ -200,10 +200,11 @@ private void SnapshotKeepMemory_Click(object sender, RoutedEventArgs e) /// button and the tab's options menu. public async Task ExportTranscriptAsync() { - if (!CanScript) return; + var core = CanScript ? TranscriptView.CoreWebView2 : null; // captured: see AppendRawAsync + if (core == null) return; try { - var json = await TranscriptView.CoreWebView2.ExecuteScriptAsync("document.documentElement.outerHTML"); + var json = await core.ExecuteScriptAsync("document.documentElement.outerHTML"); var html = JsonSerializer.Deserialize(json) ?? ""; var picker = new Windows.Storage.Pickers.FileSavePicker(); diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs index 8af92a7..9a3aca5 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.Transcript.cs @@ -124,12 +124,29 @@ await AppendRawAsync(_html.Dim( catch { /* memory restore is best-effort; a fresh conversation always works */ } } + /// + /// Writes a fragment straight into the transcript document, bypassing the _pendingHtml + /// queue on purpose: this runs during journal replay, while _webViewReady is still false so + /// that live blocks keep queueing BEHIND the restored history. That's also why it can't use + /// — which tests _webViewReady and would send the replay to the + /// queue it's meant to precede. + /// + /// The core is captured into a local instead of being dereferenced off the property twice: replay + /// awaits between chunks, and closing (or unparenting) the tab in that window unloads the WebView + /// and nulls CoreWebView2 — which is a NullReferenceException on the next chunk. The catch + /// below always swallowed it, so it never crashed anything, but it broke into the debugger on + /// every occurrence and abandoned the rest of the replay on an exception path. + /// private async Task AppendRawAsync(string html) { + if (_shutDown) return; + + var core = TranscriptView.CoreWebView2; + if (core == null) return; // WebView gone (tab closed mid-replay) — nothing to render into + try { - await TranscriptView.CoreWebView2.ExecuteScriptAsync( - $"window.__append({JsonSerializer.Serialize(html)})"); + await core.ExecuteScriptAsync($"window.__append({JsonSerializer.Serialize(html)})"); } catch { /* transient during navigation/teardown — a fragment failing to render is not fatal */ } } @@ -137,7 +154,12 @@ await TranscriptView.CoreWebView2.ExecuteScriptAsync( private async void AppendHtml(string html) { if (_shutDown) return; - if (!CanScript) + + // Capture once — CanScript reads the property, and re-reading it at the call site is a race + // against the WebView unloading. A block that arrives with no live core is queued, not + // dropped, so it still renders if the WebView comes back. + var core = CanScript ? TranscriptView.CoreWebView2 : null; + if (core == null) { _pendingHtml.Enqueue(html); return; @@ -145,8 +167,7 @@ private async void AppendHtml(string html) try { - await TranscriptView.CoreWebView2.ExecuteScriptAsync( - $"window.__append({JsonSerializer.Serialize(html)})"); + await core.ExecuteScriptAsync($"window.__append({JsonSerializer.Serialize(html)})"); } catch { @@ -156,8 +177,9 @@ await TranscriptView.CoreWebView2.ExecuteScriptAsync( private async void ClearTranscript() { - if (!CanScript) return; - try { await TranscriptView.CoreWebView2.ExecuteScriptAsync("window.__clear()"); } + var core = CanScript ? TranscriptView.CoreWebView2 : null; + if (core == null) return; + try { await core.ExecuteScriptAsync("window.__clear()"); } catch { /* transient during navigation/teardown — clearing a gone WebView is a no-op */ } } diff --git a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs index ad2e63d..d4899db 100644 --- a/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs +++ b/src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs @@ -296,8 +296,10 @@ public async void ApplyTheme() if (_shutDown) return; var theme = ThemeManager.Current; TranscriptView.DefaultBackgroundColor = ThemeManager.C(theme.Background); - if (!CanScript) return; - try { await TranscriptView.CoreWebView2.ExecuteScriptAsync(ThemeManager.BuildTranscriptScript(theme)); } + + var core = CanScript ? TranscriptView.CoreWebView2 : null; // captured: see AppendRawAsync + if (core == null) return; + try { await core.ExecuteScriptAsync(ThemeManager.BuildTranscriptScript(theme)); } catch { /* WebView gone (window closing) — nothing to recolor */ } } From bfbd3268ff2bb8885f0c544d75fc81fc9a471663 Mon Sep 17 00:00:00 2001 From: Armando Fernandez Date: Sat, 25 Jul 2026 17:49:10 -0700 Subject: [PATCH 2/2] Add Notes: an app-wide jot pad with an assistant that can read but not write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A place to write things down without leaving the app. A new Notes rail icon opens a docked panel beside the chat; New creates a plain text file under ~/.mandocode/notes and opens an editor that autosaves as you type. Notes are app-wide, the same call as snapshots and session history. A note is something you want to write down now — often between projects, or before an agent is open — so nothing here requires one. What survives of "which project was this about" is a plain subfolder: a new note is filed under the active agent's folder name when there is one, and sits loose at the top when there isn't. Grouping therefore costs no metadata and cannot drift; you re-file a note by dragging it in Explorer. The filesystem is the store — no index, no JSON — which is also why the pad lives in ~/.mandocode beside the CLI's config rather than in LocalAppData: these are the user's files, meant to be greppable, syncable, and openable anywhere. Discovery walks one folder plus its immediate subfolders, so a note written in Notepad shows up and one deleted outside the app disappears, with no row pointing at a file that isn't there. Both surfaces carry a prompt bar. Replies land in the bar's own strip and reach a note only through Insert (at the cursor, replacing the selection) or Replace note. On an open note the question carries the LIVE editor buffer, so the model sees the note as it is right now, including keystrokes autosave hasn't written yet; only the current message carries it, so a long thread doesn't ship stale copies. On the list the question covers the pad — every title and first line, plus the full text of whatever the search box matches — and the bar states what it was given ("12 notes listed · 3 read in full"), because a capped read that looks total is the one thing an "ask about all my notes" box must not do. NoteAssistant builds a bare Ollama kernel with no plugins, filters, or tools, the same shape as SnapshotEnhancer. That is the design, not an optimization: with no file access, "nothing writes your note but you" is true by construction rather than by policy, so no approval machinery is needed. It is not an AIService agent — those exist to change your files, and are scoped to a project root the pad deliberately sits outside of. The editor is not the only thing that writes these files. A FileSystemWatcher compares the file against what the editor last wrote: identical means the write was ours, changed-while-clean is adopted silently, and changed-while-you-were-typing raises a conflict the user resolves. A note deleted from under unsaved edits offers to save it back. No path silently discards typing. NoteText owns the newline round trip. A WinUI TextBox normalizes every newline to a bare CR, which caused two bugs found by watching the app rather than the compiler: writing Editor.Text straight back out turned a Notepad-authored CRLF note into one endless line, and comparing the loaded file against Editor.Text made merely OPENING a note look like an edit, autosaving untouched files. Also here: the docked left column now tracks one current-panel value instead of a bool per panel (three mutually exclusive panels had states that shouldn't exist), and two ambiguous automation names are fixed — the ask bar's send button no longer shares the name "Send" with the chat's, and Insert/Replace carry stable accessible names now that their visible labels change with the selection. 42 store/entry tests and 8 newline tests, driven against real temp folders (the pad root is a constructor argument, not a hard-coded path); 177 total. Verified in the running app through UI Automation: New, autosave, ask, streamed reply, Insert landing in the file, external-edit adoption, CRLF preservation, and rename. --- CHANGELOG.md | 40 ++ README.md | 77 ++- .../MandoCode.Desktop.Tests.csproj | 10 + src/MandoCode.Desktop.Tests/NoteStoreTests.cs | 379 +++++++++++++ src/MandoCode.Desktop.Tests/NoteTextTests.cs | 88 ++++ .../Controls/NoteAskBar.xaml | 113 ++++ .../Controls/NoteAskBar.xaml.cs | 226 ++++++++ .../Controls/NoteEditorPane.Actions.cs | 113 ++++ .../Controls/NoteEditorPane.Sync.cs | 186 +++++++ .../Controls/NoteEditorPane.xaml | 122 +++++ .../Controls/NoteEditorPane.xaml.cs | 327 ++++++++++++ src/MandoCode.Desktop/MainWindow.History.cs | 9 +- .../MainWindow.Navigation.cs | 5 +- src/MandoCode.Desktop/MainWindow.Notes.cs | 496 ++++++++++++++++++ src/MandoCode.Desktop/MainWindow.Snapshots.cs | 43 +- src/MandoCode.Desktop/MainWindow.Tabs.cs | 3 +- .../MainWindow.ViewModels.cs | 41 ++ src/MandoCode.Desktop/MainWindow.xaml | 174 ++++++ src/MandoCode.Desktop/MainWindow.xaml.cs | 23 +- .../Services/NoteAssistant.cs | 205 ++++++++ src/MandoCode.Desktop/Services/NoteEntry.cs | 58 ++ src/MandoCode.Desktop/Services/NoteStore.cs | 311 +++++++++++ src/MandoCode.Desktop/Services/NoteText.cs | 36 ++ src/MandoCode.Desktop/Services/PanelState.cs | 15 +- 24 files changed, 3068 insertions(+), 32 deletions(-) create mode 100644 src/MandoCode.Desktop.Tests/NoteStoreTests.cs create mode 100644 src/MandoCode.Desktop.Tests/NoteTextTests.cs create mode 100644 src/MandoCode.Desktop/Controls/NoteAskBar.xaml create mode 100644 src/MandoCode.Desktop/Controls/NoteAskBar.xaml.cs create mode 100644 src/MandoCode.Desktop/Controls/NoteEditorPane.Actions.cs create mode 100644 src/MandoCode.Desktop/Controls/NoteEditorPane.Sync.cs create mode 100644 src/MandoCode.Desktop/Controls/NoteEditorPane.xaml create mode 100644 src/MandoCode.Desktop/Controls/NoteEditorPane.xaml.cs create mode 100644 src/MandoCode.Desktop/MainWindow.Notes.cs create mode 100644 src/MandoCode.Desktop/Services/NoteAssistant.cs create mode 100644 src/MandoCode.Desktop/Services/NoteEntry.cs create mode 100644 src/MandoCode.Desktop/Services/NoteStore.cs create mode 100644 src/MandoCode.Desktop/Services/NoteText.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index b368410..0f42915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,46 @@ are visible until you actually open a second tab. `SkillCoordinator` fans skill changes out to every open agent, mirroring `McpCoordinator`. - **Branded app icon** across the exe, taskbar, and window title bar, plus a lightweight unhandled-exception logger (`crash.log`) to speed up diagnosing native/COM failures. +- **Notes - a jot pad with a prompt attached.** A **Notes** rail panel for writing things down without + leaving the app. **New** creates a plain text file under `~/.mandocode/notes` (beside the config file + the CLI shares) and opens an editor docked next to the chat: autosave on a 1.2s debounce plus Ctrl+S, + rename in place, Show in Explorer. Notes are **app-wide**, the same call as snapshots and session + history - a note is something you want to write down *now*, often between projects or before an agent + is even open, so nothing here needs one. What survives of "which project was this about" is a plain + SUBFOLDER: a new note is filed under the active agent's folder name when there is one, and sits loose + at the top when there isn't. Grouping therefore costs no metadata and cannot drift - you re-file a + note by dragging it in Explorer. + - **The filesystem is the store.** No notes index, no JSON, which is also why the pad lives in + `~/.mandocode` rather than LocalAppData: these are your files, meant to be greppable, syncable, and + openable in any editor. Discovery walks one folder plus its immediate subfolders (one level only - + a jot pad with a hierarchy is a filing system, and search is the better answer to "where did I put + it"). In exchange no row can point at a file that isn't there: a note written in Notepad shows up, + one deleted outside the app disappears. Search matches note BODIES and quotes the matching line. + - **A prompt bar under both surfaces.** Chat-shaped, but the document above it is your note rather + than a transcript: replies land in the bar's own strip and reach a note only through **Insert** (at + the cursor, replacing the selection if there is one) or **Replace note**. On an open note the + question carries the LIVE editor buffer, so the model always sees the note as it is right now - + including keystrokes autosave hasn't written yet - and only the current message carries it, so a + long thread doesn't ship stale copies. On the list the question is about the pad: every note's + title and first line plus the full text of whatever the search box is matching, with the bar + stating what it was given (`12 notes listed - 3 read in full`), because a capped read that looks + total is the one thing an "ask about all my notes" box must not do. + - **The assistant has no tools, by design.** `NoteAssistant` builds a bare Ollama kernel with no + plugins, filters, or tools - the same shape as `SnapshotEnhancer`. With no file access, "nothing + writes your note but you" is true by construction rather than by policy, so no approval machinery + is needed: the only route from a reply into a note is a button you pressed. Its model comes from + the chip under the prompt (defaulting to the app-wide default) and is remembered; the thread is + per-note, in memory, and cleared when you switch notes - notes aren't conversations. + - **The editor is not the only writer, and doesn't assume it is.** These are plain files, so Notepad, + VS Code, a sync client, or git can change one under you. A `FileSystemWatcher` compares the file + against what the editor last wrote: identical means the write was ours, changed-while-clean is + adopted silently, and changed-while-you-were-typing raises a conflict you resolve - *use the + version on disk* or *keep what I typed*. A note deleted from under unsaved edits offers to save it + back. No path silently discards typing. + - `NoteText` owns the newline round trip: a WinUI `TextBox` normalizes every newline to a bare CR, so + writing `Editor.Text` straight back out would turn a Notepad-authored CRLF note into one endless + line - and comparing the loaded file text against `Editor.Text` made merely OPENING a note look + like an edit, which autosaved untouched files. Both are covered by tests. ### Changed - **Closing the last agent is allowed.** The app no longer forces at least one agent open — closing diff --git a/README.md b/README.md index 811def2..aa6a301 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ graph; `SessionManager` owns the set of them. The split matters: | `AIService` (its conversation, its model), `ChatController`, `TaskPlannerService` | The `MandoCodeConfig` on disk — the **defaults** a new agent starts on | | `MandoCodeConfig` clone, `ProjectRootAccessor`, `SkillLoader`, `FileAutocompleteProvider` | `McpClientManager` (one set of server processes) | | `TokenTrackingService`, `PlanHandoff`, `TranscriptWriter`, `BusyStateService`, `ShellRunner` | `MusicPlayerService`, `ThemeManager` (static), `TranscriptHtmlBuilder`, `SpinnerService` | -| `WinUiApprovalService`, `ApprovalPromptGate`, `McpApprovalGate` | `ConfigCoordinator`, `McpCoordinator`, `SkillCoordinator`, `SessionManager`, `SnapshotStore`, `SessionArchiveStore`, `UiUpdateCheckService` | +| `WinUiApprovalService`, `ApprovalPromptGate`, `McpApprovalGate` | `ConfigCoordinator`, `McpCoordinator`, `SkillCoordinator`, `SessionManager`, `SnapshotStore`, `SessionArchiveStore`, `NoteStore`, `UiUpdateCheckService` | Tabs default to `Agent 1`, `Agent 2`, … (the folder shows in the header); the number reuses the lowest free slot, and a rename or folder change never overwrites it. Each tab's `⋯` options menu @@ -181,6 +181,67 @@ transcript and — when the model supports it — rehydrates the full memory. `/ conversation for good; only *closing* softened from "gone" to "recoverable." The archive is capped (newest 60); evicting a row deletes its journals so the on-disk stores stay bounded. +### Notes (the jot pad) + +The **Notes** rail panel is an always-there scratchpad: plain text files under +`~\.mandocode\notes`, beside the config file the CLI shares. **New** creates one and opens an editor docked next to the +chat — autosaved on a 1.2s debounce (plus Ctrl+S, leaving the note, closing the panel, closing the +window), renameable in place, with **Show in Explorer**. + +Notes are **app-wide**, the same call as snapshots and session history. A note is something you want +to write down *now*, which is often between projects or before an agent is even open, so nothing here +needs one. What survives of "which project was this about" is a plain **subfolder**: a new note is +filed under the active agent's folder name when there is one, and sits loose at the top when there +isn't. Grouping therefore costs no metadata, can't drift, and is corrected by dragging files around +in Explorer. + +**The filesystem is the store** — no index, no JSON, which is also why the pad lives in +`~\.mandocode` rather than LocalAppData: these are your files, meant to be greppable, syncable, and +openable in any editor. Discovery walks one folder plus its immediate subfolders (one level only — a +jot pad with a hierarchy is a filing system, and the search box is a better answer to "where did I +put it"). In exchange, no row can ever point at a file that isn't there: a note added in Notepad +shows up, and one deleted outside the app disappears. Search matches note **bodies** and quotes the +matching line on the card; only search and the assistant see a body truncated, capped at +`NoteStore.MaxTextBytes` (128 KB). + +#### The prompt bar + +Both surfaces — the open note and the list — carry a prompt at the bottom. It's chat-shaped, but the +document above it is your note rather than a transcript, so replies land in the bar's own strip and +reach a note only through **Insert** (at the cursor, replacing the selection if there is one) or +**Replace note**. With text selected, the question is about the selection. + +The note's text travels **in the message**, taken from the live editor buffer — so the model always +sees the note as it is right now, including keystrokes autosave hasn't written yet, with nothing to +fall out of sync. Only the current message carries the note; earlier turns in the thread keep just +their words, so a long back-and-forth doesn't ship five stale copies. On the list, the question is +about the pad: every note's title and first line, plus the full text of whatever the search box is +currently matching, and the bar states what it was given (`12 notes listed · 3 read in full`) because +a capped read that looks total is the one thing an "ask about all my notes" box must not do. + +`Services/NoteAssistant.cs` builds a bare Ollama kernel with **no plugins, filters, or tools** — the +same shape as `SnapshotEnhancer`. That's the design, not an optimization: with no file tools, "nothing +writes your note but you" is true by construction rather than by policy, and no approval machinery is +needed because the only route from a reply into a note is a button you pressed. It also isn't an +`AIService` agent, because those exist to change your files (the opposite of what a notepad wants) +and are scoped to a project root the pad deliberately sits outside of. Its model is picked from the +chip under the prompt, defaults to the app-wide default model, and is remembered; the thread is +per-note, in memory, and cleared when you switch notes — notes aren't conversations. + +The editor is not the only thing that writes these files, and doesn't assume it is. A +`FileSystemWatcher` compares the file against what the editor last wrote: identical means the write +was ours; changed-while-clean is adopted silently; changed-while-you-were-typing raises a conflict you +resolve — *use the version on disk* or *keep what I typed*. A note deleted from under unsaved edits +offers to save it back. `Services/NoteText.cs` handles the newline round trip, because a WinUI +`TextBox` normalizes every newline to a bare CR — write that straight back out and a Notepad-authored +CRLF note becomes one endless line. Both of those are why `NoteStore`, `NoteEntry`, and `NoteText` are +WinUI-free with an injectable pad root: the tests drive them against real temp folders. + +Notes deliberately lack the **Delete all *n*** group button that Snapshots and History carry: those +are artifacts the app generated, a note is something you wrote by hand, and one button that deletes a +folder's worth of writing is a different class of risk. + + ### Why the tab strip isn't a `TabView` WinUI's `TabView` hosts only the selected item's content, which detaches the previous tab and @@ -234,6 +295,20 @@ within 24 hours. model you pick) and Import it into another model or a fresh agent; a global left-rail panel lists them, **persisted**, grouped by project, searchable, with collapsible groups. Unnamed snapshots get an auto-generated, unique title +- Notes — a **Notes** rail panel that jots into the project itself: **New** writes + a plain text file under `~\.mandocode\notes` and opens an editor beside the chat + (autosave, rename in place, Show in Explorer). App-wide like snapshots, so a note never + needs an agent open; new notes are filed under the folder you're working in when there is + one, which is what the panel groups by. The filesystem is the store — no index, so a note + written in Notepad appears and one deleted outside the app is gone. Searchable across note + bodies with the matching line quoted +- A prompt bar under both notes surfaces — ask about the open note (it's sent the live + buffer, so the model always sees what you see) or about the whole pad, with the bar + stating what it was given. Replies land in their own strip and reach a note only via + **Insert** / **Replace note**; the assistant has no tools at all, so "nothing writes your + note but you" is structural. External edits are adopted into the open editor, while an + external change landing on unsaved typing raises a conflict you resolve — never a silent + overwrite - Rail badges on History and Snapshots are unread counts that clear when you open the panel (persisted), not running totals - Integrated terminal — a sliding panel (Ctrl+` / Ctrl+Shift+` to maximize) running a diff --git a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj index 975d03f..5753791 100644 --- a/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj +++ b/src/MandoCode.Desktop.Tests/MandoCode.Desktop.Tests.csproj @@ -46,6 +46,16 @@ + + + + + + diff --git a/src/MandoCode.Desktop.Tests/NoteStoreTests.cs b/src/MandoCode.Desktop.Tests/NoteStoreTests.cs new file mode 100644 index 0000000..ff7cf55 --- /dev/null +++ b/src/MandoCode.Desktop.Tests/NoteStoreTests.cs @@ -0,0 +1,379 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// Tests for the jot pad against real temp folders. Notes are the one store in the app that IS the +/// filesystem — there's no JSON index to assert against, so these verify the actual claim: what's in +/// the pad folder is what the panel shows, and grouping is a subfolder rather than metadata. +/// +public sealed class NoteStoreTests : IDisposable +{ + private readonly string _root; + private readonly NoteStore _store; + + public NoteStoreTests() + { + _root = Path.Combine(Path.GetTempPath(), "mandocode-pad-" + Guid.NewGuid().ToString("N")); + _store = new NoteStore(_root); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch { } + } + + /// Writes a note directly, as Notepad or a sync client would — bypassing the store. + private string WriteNote(string relative, string content) + { + var path = Path.Combine(_root, relative); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } + + // ---- discovery ---- + + [Fact] + public void Discover_finds_a_note_written_outside_the_app() + { + WriteNote("ideas.txt", "first line\nsecond line"); + + var note = Assert.Single(_store.Discover()); + + Assert.Equal("ideas.txt", note.FileName); + Assert.Equal("ideas", note.Title); + Assert.Equal("", note.Group); + Assert.Equal("Unfiled", note.GroupLabel); + Assert.Equal("first line", note.Preview); + } + + [Fact] + public void Discover_is_empty_when_the_pad_folder_does_not_exist_yet() + { + // Nothing is pre-created — a fresh install has no pad until the first note. + Assert.False(Directory.Exists(_root)); + Assert.Empty(_store.Discover()); + } + + [Fact] + public void Discover_reads_one_level_of_subfolders_as_groups() + { + WriteNote("loose.txt", "unfiled"); + WriteNote(Path.Combine("MandoCode.Desktop", "panel.txt"), "filed under a project"); + + var notes = _store.Discover(); + + Assert.Equal(2, notes.Count); + Assert.Equal("MandoCode.Desktop", notes.Single(n => n.FileName == "panel.txt").Group); + Assert.Equal("", notes.Single(n => n.FileName == "loose.txt").Group); + } + + [Fact] + public void Discover_does_not_recurse_past_one_level() + { + // A jot pad with a folder hierarchy is a filing system; search is the better answer. + WriteNote(Path.Combine("project", "deep", "buried.txt"), "too deep"); + + Assert.Empty(_store.Discover()); + } + + [Fact] + public void Discover_skips_dot_folders_and_non_note_files() + { + WriteNote("keep.txt", "text note"); + WriteNote("keep.md", "# markdown note"); + WriteNote("skip.png", "not a note"); + WriteNote(Path.Combine(".git", "config.txt"), "somebody else's business"); + + var names = _store.Discover().Select(n => n.FileName).OrderBy(n => n).ToList(); + + Assert.Equal(new[] { "keep.md", "keep.txt" }, names); + } + + [Fact] + public void Discover_returns_newest_first_across_groups() + { + var old = WriteNote(Path.Combine("proj", "old.txt"), "old"); + var recent = WriteNote("new.txt", "new"); + File.SetLastWriteTime(old, DateTime.Now.AddDays(-3)); + File.SetLastWriteTime(recent, DateTime.Now); + + Assert.Equal(new[] { "new.txt", "old.txt" }, _store.Discover().Select(n => n.FileName).ToArray()); + } + + [Fact] + public void Discover_caps_how_much_of_a_note_is_read() + { + WriteNote("big.txt", new string('x', NoteStore.MaxTextBytes + 5_000)); + + var note = Assert.Single(_store.Discover()); + + // The row still reports the true size; only the searchable/assistant-visible body is capped. + Assert.Equal(NoteStore.MaxTextBytes, note.Text.Length); + Assert.Equal(NoteStore.MaxTextBytes + 5_000, note.Bytes); + } + + // ---- create ---- + + [Fact] + public void Create_makes_the_pad_folder_on_first_use() + { + var note = _store.Create(); + + Assert.True(File.Exists(note.Path)); + Assert.Equal("Untitled.txt", note.FileName); + Assert.Equal(_root, Path.GetDirectoryName(note.Path)); + Assert.Equal("", File.ReadAllText(note.Path)); + Assert.Equal("", note.Group); + } + + [Fact] + public void Create_files_a_note_under_its_group_folder() + { + var note = _store.Create(group: "MandoCode.Desktop"); + + Assert.Equal("MandoCode.Desktop", note.Group); + Assert.Equal(Path.Combine(_root, "MandoCode.Desktop"), Path.GetDirectoryName(note.Path)); + Assert.True(File.Exists(note.Path)); + } + + [Fact] + public void Create_sanitizes_a_group_that_cannot_be_a_folder_name() + { + var note = _store.Create(group: "weird:name/here"); + + Assert.Equal("weird name here", note.Group); + Assert.True(File.Exists(note.Path)); + } + + [Fact] + public void Create_uniquifies_within_its_folder_instead_of_overwriting() + { + var first = _store.Create(); + var second = _store.Create(); + var third = _store.Create(title: "Untitled"); + // Same name in a different group is a different file, so it keeps the plain name. + var grouped = _store.Create(group: "proj"); + + Assert.Equal("Untitled.txt", first.FileName); + Assert.Equal("Untitled 2.txt", second.FileName); + Assert.Equal("Untitled 3.txt", third.FileName); + Assert.Equal("Untitled.txt", grouped.FileName); + } + + [Fact] + public void Create_falls_back_to_a_default_name_for_an_unusable_title() + { + Assert.Equal("Untitled.txt", _store.Create(title: " /// ").FileName); + } + + [Fact] + public void Create_raises_Changed() + { + var fired = 0; + _store.Changed += () => fired++; + + _store.Create(); + + Assert.Equal(1, fired); + } + + // ---- rename ---- + + [Fact] + public void Rename_keeps_the_note_in_its_group_and_keeps_its_text() + { + var note = _store.Create(group: "proj"); + File.WriteAllText(note.Path, "body text"); + + var moved = _store.Rename(note, "Q3 rollout"); + + Assert.NotNull(moved); + Assert.Equal("Q3 rollout.txt", moved!.FileName); + Assert.Equal("proj", moved.Group); + Assert.Equal(Path.Combine(_root, "proj"), Path.GetDirectoryName(moved.Path)); + Assert.False(File.Exists(note.Path)); + Assert.Equal("body text", File.ReadAllText(moved.Path)); + } + + [Fact] + public void Rename_sanitizes_a_title_that_cannot_be_a_file_name() + { + var note = _store.Create(); + + var moved = _store.Rename(note, "ideas: rollout/plan?"); + + Assert.Equal("ideas rollout plan.txt", moved!.FileName); + } + + [Fact] + public void Rename_keeps_the_extension() + { + WriteNote("thoughts.md", "# hi"); + var note = Assert.Single(_store.Discover()); + + var moved = _store.Rename(note, "thoughts v2"); + + Assert.Equal("thoughts v2.md", moved!.FileName); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("Untitled")] // same title — a no-op, not a rename to "Untitled 2" + public void Rename_declines_a_pointless_title(string title) + { + var note = _store.Create(); + + Assert.Null(_store.Rename(note, title)); + Assert.True(File.Exists(note.Path)); + } + + // ---- delete / reread ---- + + [Fact] + public void Delete_removes_the_file() + { + var note = _store.Create(); + + Assert.True(_store.Delete(note)); + Assert.False(File.Exists(note.Path)); + } + + [Fact] + public void Delete_of_an_already_gone_note_still_succeeds() + { + var note = _store.Create(); + File.Delete(note.Path); + + Assert.True(_store.Delete(note)); + } + + [Fact] + public void Reread_picks_up_an_edit_made_outside_the_app() + { + var note = _store.Create(); + File.WriteAllText(note.Path, "written in VS Code\nand this"); + + var fresh = _store.Reread(note); + + Assert.NotNull(fresh); + Assert.Equal("written in VS Code", fresh!.Preview); + Assert.Equal(File.ReadAllText(note.Path).Length, (int)fresh.Bytes); + } + + [Fact] + public void Reread_reports_a_deleted_note_as_gone() + { + var note = _store.Create(); + File.Delete(note.Path); + + Assert.Null(_store.Reread(note)); + } + + // ---- pure helpers ---- + + [Theory] + [InlineData("plain", "plain")] + [InlineData("ac:d\"e/f\\g|h?i*j", "a b c d e f g h i j")] + [InlineData(" spaced out ", "spaced out")] + [InlineData("...hidden", "hidden")] // a leading dot hides the file (and dot-dirs are skipped) + [InlineData("", "")] + [InlineData("////", "")] + public void SanitizeTitle_produces_a_legal_name(string input, string expected) + { + Assert.Equal(expected, NoteStore.SanitizeTitle(input)); + } + + [Fact] + public void UniquePath_collides_case_insensitively() + { + // Windows won't hold both "ideas.txt" and "Ideas.txt"; silently overwriting one with the other + // is the worst outcome a notes app can have. + WriteNote("ideas.txt", "x"); + + var path = NoteStore.UniquePath(_root, "IDEAS", ".txt"); + + Assert.Equal("IDEAS 2.txt", Path.GetFileName(path)); + } + + [Theory] + [InlineData("", "")] + [InlineData(" \n\n ", "")] + [InlineData("\n\n the gist \nmore", "the gist")] + [InlineData("# Heading\nbody", "Heading")] + [InlineData("- a bullet", "a bullet")] + public void Preview_is_the_first_line_that_says_something(string text, string expected) + { + Assert.Equal(expected, NoteStore.Preview(text)); + } + + [Fact] + public void Preview_caps_a_long_line_with_an_ellipsis() + { + var preview = NoteStore.Preview(new string('a', 400)); + + Assert.EndsWith("…", preview); + Assert.True(preview.Length < 200, $"preview was {preview.Length} chars"); + } + + [Fact] + public void Matches_hits_on_name_group_and_body() + { + WriteNote(Path.Combine("widget-api", "ideas.txt"), "line one\nremember the rate limiter\nthree"); + var note = Assert.Single(_store.Discover()); + + Assert.True(NoteStore.Matches(note, "ideas")); // name + Assert.True(NoteStore.Matches(note, "widget-api")); // group + Assert.True(NoteStore.Matches(note, "RATE LIMITER")); // body, case-insensitive + Assert.True(NoteStore.Matches(note, "")); // empty query shows everything + Assert.False(NoteStore.Matches(note, "nonsense")); + } + + [Fact] + public void MatchSnippet_quotes_the_matching_body_line() + { + WriteNote("ideas.txt", "the gist\nremember the rate limiter"); + var note = Assert.Single(_store.Discover()); + + Assert.Equal("remember the rate limiter", NoteStore.MatchSnippet(note, "rate limiter")); + } + + [Fact] + public void MatchSnippet_is_null_when_the_hit_is_already_on_the_card() + { + WriteNote("ideas.txt", "the gist\nmore text"); + var note = Assert.Single(_store.Discover()); + + Assert.Null(NoteStore.MatchSnippet(note, "gist")); + Assert.Null(NoteStore.MatchSnippet(note, "nonsense")); + } + + [Theory] + [InlineData(0, "empty")] + [InlineData(412, "412 B")] + [InlineData(3174, "3.1 KB")] + public void SizeLabel_reads_like_a_file_size(long bytes, string expected) + { + var note = new NoteEntry + { + Path = Path.Combine(_root, "a.txt"), + Group = "", + ModifiedAt = DateTimeOffset.Now, + Bytes = bytes, + Preview = "", + Text = "", + }; + + Assert.Equal(expected, note.SizeLabel); + } + + [Fact] + public void DefaultRoot_is_the_mandocode_folder() + { + // Beside the CLI's own config.json, not in LocalAppData: these are the user's files. + Assert.EndsWith(Path.Combine(".mandocode", "notes"), NoteStore.DefaultRoot); + } +} diff --git a/src/MandoCode.Desktop.Tests/NoteTextTests.cs b/src/MandoCode.Desktop.Tests/NoteTextTests.cs new file mode 100644 index 0000000..3f1568d --- /dev/null +++ b/src/MandoCode.Desktop.Tests/NoteTextTests.cs @@ -0,0 +1,88 @@ +using MandoCode.Desktop.Services; +using Xunit; + +namespace MandoCode.Desktop.Tests; + +/// +/// Regression tests for the note editor's newline handling. A WinUI TextBox holds every newline as a +/// bare CR, so a round trip through the editor has to restore the note's own convention. Two real +/// bugs came out of this, both caught by watching the app rather than the compiler: a CRLF note being +/// rewritten as CR-only (one endless line in Notepad), and every OPEN registering as an edit, which +/// autosaved untouched notes. +/// +public sealed class NoteTextTests +{ + [Fact] + public void DetectNewline_prefers_CRLF_when_the_file_has_any() + { + Assert.Equal("\r\n", NoteText.DetectNewline("a\r\nb")); + Assert.Equal("\r\n", NoteText.DetectNewline("a\nb\r\nc")); // mixed: CRLF wins + } + + [Fact] + public void DetectNewline_keeps_LF_for_an_LF_only_file() + { + Assert.Equal("\n", NoteText.DetectNewline("a\nb\nc")); + } + + [Fact] + public void DetectNewline_falls_back_to_the_platform_default() + { + // Nothing to copy: a new or single-line note. + Assert.Equal(Environment.NewLine, NoteText.DetectNewline("")); + Assert.Equal(Environment.NewLine, NoteText.DetectNewline("one line, no newline")); + } + + [Fact] + public void ToFileText_restores_CRLF_from_the_editor_form() + { + // What the TextBox hands back after a two-line note is edited: bare CRs. + Assert.Equal("a\r\nb\r\nc", NoteText.ToFileText("a\rb\rc", "\r\n")); + } + + [Fact] + public void ToFileText_restores_LF_for_an_LF_note() + { + Assert.Equal("a\nb", NoteText.ToFileText("a\rb", "\n")); + } + + [Fact] + public void ToFileText_never_leaves_a_bare_CR_behind() + { + var result = NoteText.ToFileText("a\rb\r\nc\nd", "\r\n"); + + Assert.Equal("a\r\nb\r\nc\r\nd", result); + // The failure mode this guards: a lone CR that Notepad renders as no break at all. + Assert.DoesNotContain('\r', result.Replace("\r\n", "")); + } + + [Fact] + public void ToFileText_is_idempotent() + { + // Saving twice with no edit in between must not double up line endings. + var once = NoteText.ToFileText("a\rb", "\r\n"); + Assert.Equal(once, NoteText.ToFileText(once, "\r\n")); + } + + [Theory] + [InlineData("")] + [InlineData("no newlines at all")] + public void ToFileText_leaves_newline_free_text_alone(string text) + { + Assert.Equal(text, NoteText.ToFileText(text, "\r\n")); + } + + [Fact] + public void A_round_trip_through_the_editor_form_preserves_the_file() + { + // The full path: file text in, editor normalization, file text back out. Equality here is + // what makes "opening a note doesn't change it" true. + foreach (var original in new[] { "a\r\nb\r\n", "a\nb\n", "single", "" }) + { + var newline = NoteText.DetectNewline(original); + var editorForm = original.Replace("\r\n", "\r").Replace('\n', '\r'); // what the TextBox does + + Assert.Equal(original, NoteText.ToFileText(editorForm, newline)); + } + } +} diff --git a/src/MandoCode.Desktop/Controls/NoteAskBar.xaml b/src/MandoCode.Desktop/Controls/NoteAskBar.xaml new file mode 100644 index 0000000..bfdbc57 --- /dev/null +++ b/src/MandoCode.Desktop/Controls/NoteAskBar.xaml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MandoCode.Desktop/Controls/NoteAskBar.xaml.cs b/src/MandoCode.Desktop/Controls/NoteAskBar.xaml.cs new file mode 100644 index 0000000..4355c78 --- /dev/null +++ b/src/MandoCode.Desktop/Controls/NoteAskBar.xaml.cs @@ -0,0 +1,226 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.ApplicationModel.DataTransfer; +using Windows.System; + +namespace MandoCode.Desktop.Controls; + +/// +/// The prompt bar at the bottom of the Notes panel. Deliberately dumb: it collects a question, shows a +/// streamed reply, and raises events. It owns no model, no thread, and no note — the window drives it, +/// because which surface is showing decides what the question is even about. +/// +/// The reply always gets the same three offers (Insert / Replace / Dismiss) rather than the bar trying +/// to classify whether a reply is prose or a proposed rewrite. Small local models tag their own output +/// unreliably, and guessing wrong in either direction is worse than letting the person who asked +/// decide: the note is only ever written by an explicit press. +/// +public sealed partial class NoteAskBar : UserControl +{ + /// A question was submitted (Enter or the send button). + public event Action? Submitted; + + /// The send button was pressed while a reply was streaming. + public event Action? CancelRequested; + + /// Insert the reply into the open note at the cursor. + public event Action? InsertRequested; + + /// Replace the open note's whole body with the reply. + public event Action? ReplaceRequested; + + /// A model was chosen from the chip's flyout. + public event Action? ModelChanged; + + private readonly System.Text.StringBuilder _reply = new(); + private bool _busy; + + public NoteAskBar() + { + InitializeComponent(); + } + + /// The reply as it stands — what Insert/Replace would apply. + public string Reply => _reply.ToString().Trim(); + + public bool IsBusy => _busy; + + /// Whether the note-writing offers are available. False on the list view, where there's no + /// open note to write into and the reply is just an answer. + public bool AllowNoteEdits { get; set; } + + public void FocusPrompt() => PromptBox.Focus(FocusState.Programmatic); + + /// Switches the bar between its two jobs. Called whenever the panel changes surface. + public void SetMode(bool noteOpen, string? noteTitle) + { + AllowNoteEdits = noteOpen; + PromptBox.PlaceholderText = noteOpen + ? $"Ask about “{noteTitle}” — or tell it what to write" + : "Ask about all your notes…"; + InsertButton.Visibility = noteOpen ? Visibility.Visible : Visibility.Collapsed; + ReplaceButton.Visibility = noteOpen ? Visibility.Visible : Visibility.Collapsed; + InsertLabel.Text = "Insert"; + } + + /// Label under the prompt saying what the model was actually given — the honest version of + /// a capped context ("12 notes listed · 3 read in full"), so a partial read never looks total. + public void SetScopeNote(string text) => ScopeText.Text = text; + + /// Notes that a selection exists, so Insert reads as replacing it rather than adding. + public void SetHasSelection(bool hasSelection) => + InsertLabel.Text = hasSelection ? "Replace selection" : "Insert"; + + // ---- model chip ---- + + /// Names the model that WOULD answer, before the model list has come back from Ollama. + /// Without this the chip reads "no model" for the first second or two of every panel open — which + /// says the bar won't work, while it in fact answers fine on the configured default. + public void SetModelLabel(string? model) => + ModelText.Text = string.IsNullOrWhiteSpace(model) ? "no model" : model; + + public void SetModels(IReadOnlyList models, string? current) + { + SetModelLabel(current); + + var flyout = new MenuFlyout + { + Placement = Microsoft.UI.Xaml.Controls.Primitives.FlyoutPlacementMode.Top, + }; + if (models.Count == 0) + { + flyout.Items.Add(new MenuFlyoutItem + { + Text = "No models found — is Ollama running?", + IsEnabled = false, + }); + } + else + { + foreach (var model in models) + { + var item = new MenuFlyoutItem { Text = model }; + var captured = model; + item.Click += (_, _) => + { + ModelText.Text = captured; + ModelChanged?.Invoke(captured); + }; + flyout.Items.Add(item); + } + } + ModelButton.Flyout = flyout; + } + + // ---- reply lifecycle (driven by the window) ---- + + /// Opens an empty reply and puts the bar into its streaming state. + public void BeginReply(string label) + { + _reply.Clear(); + ReplyText.Text = ""; + ReplyLabel.Text = label; + ReplyBox.Visibility = Visibility.Visible; + ActionRow.Visibility = Visibility.Collapsed; + ReplyRing.IsActive = true; + ReplyRing.Visibility = Visibility.Visible; + SetBusy(true); + } + + /// Appends a streamed chunk. Must be called on the UI thread. + public void AppendDelta(string text) + { + if (text.Length == 0) return; + _reply.Append(text); + ReplyText.Text = _reply.ToString(); + ReplyScroller.ChangeView(null, ReplyScroller.ScrollableHeight, null, disableAnimation: true); + } + + /// Closes a reply. replaces the body when the call failed — a + /// silent empty strip would read as "the model had nothing to say". + public void EndReply(string? error = null, string? label = null) + { + SetBusy(false); + ReplyRing.IsActive = false; + ReplyRing.Visibility = Visibility.Collapsed; + + if (error != null) + { + ReplyLabel.Text = "couldn't answer"; + ReplyText.Text = error; + ActionRow.Visibility = Visibility.Collapsed; + return; + } + + if (label != null) ReplyLabel.Text = label; + + var empty = Reply.Length == 0; + if (empty) ReplyText.Text = "(no answer came back)"; + ActionRow.Visibility = empty ? Visibility.Collapsed : Visibility.Visible; + } + + /// Clears the reply — on switching notes, or on Dismiss. + public void ClearReply() + { + _reply.Clear(); + ReplyText.Text = ""; + ReplyBox.Visibility = Visibility.Collapsed; + ActionRow.Visibility = Visibility.Collapsed; + ReplyRing.IsActive = false; + ReplyRing.Visibility = Visibility.Collapsed; + } + + private void SetBusy(bool busy) + { + _busy = busy; + // The send button doubles as stop: one control, and there's only ever one request in flight. + SendIcon.Glyph = busy ? "" : ""; + ToolTipService.SetToolTip(SendButton, busy ? "Stop" : "Ask (Enter)"); + PromptBox.IsEnabled = !busy; + } + + // ---- input ---- + + private void PromptBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key != VirtualKey.Enter) return; + e.Handled = true; + Submit(); + } + + private void Send_Click(object sender, RoutedEventArgs e) + { + if (_busy) CancelRequested?.Invoke(); + else Submit(); + } + + private void Submit() + { + if (_busy) return; + var question = PromptBox.Text.Trim(); + if (question.Length == 0) return; + PromptBox.Text = ""; + Submitted?.Invoke(question); + } + + private void Insert_Click(object sender, RoutedEventArgs e) + { + if (Reply.Length > 0) InsertRequested?.Invoke(Reply); + } + + private void Replace_Click(object sender, RoutedEventArgs e) + { + if (Reply.Length > 0) ReplaceRequested?.Invoke(Reply); + } + + private void Copy_Click(object sender, RoutedEventArgs e) + { + if (Reply.Length == 0) return; + var package = new DataPackage { RequestedOperation = DataPackageOperation.Copy }; + package.SetText(Reply); + Clipboard.SetContent(package); + } + + private void Dismiss_Click(object sender, RoutedEventArgs e) => ClearReply(); +} diff --git a/src/MandoCode.Desktop/Controls/NoteEditorPane.Actions.cs b/src/MandoCode.Desktop/Controls/NoteEditorPane.Actions.cs new file mode 100644 index 0000000..d881c27 --- /dev/null +++ b/src/MandoCode.Desktop/Controls/NoteEditorPane.Actions.cs @@ -0,0 +1,113 @@ +using MandoCode.Desktop.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.System; + +namespace MandoCode.Desktop.Controls; + +/// +/// The note's own actions: rename in place, reveal, delete. Split out from the editor proper because +/// none of it touches the buffer — they act on the FILE, through the store. +/// +public sealed partial class NoteEditorPane +{ + private bool _renaming; + + // ---- rename ---- + + private void Rename_Click(object sender, RoutedEventArgs e) => BeginRename(); + + private void BeginRename() + { + if (Current == null) return; + _renaming = true; + RenameBox.Text = Current.Title; + RenameBox.Visibility = Visibility.Visible; + TitleText.Visibility = Visibility.Collapsed; + RenameBox.Focus(FocusState.Programmatic); + RenameBox.SelectAll(); + } + + private void EndRename(bool commit) + { + if (!_renaming) return; + _renaming = false; + + RenameBox.Visibility = Visibility.Collapsed; + TitleText.Visibility = Visibility.Visible; + + if (!commit || Current == null || Store == null) return; + + // Save first: the rename moves the file, and a pending autosave would then write to a path that + // no longer exists. + FlushPendingSave(); + + var moved = Store.Rename(Current, RenameBox.Text); + if (moved == null) + { + SetStatus("Rename didn't stick — that name may be in use or the file is locked"); + return; + } + + Current = moved; + StartWatching(moved.Path); // the old watcher was filtered to the old file name + RefreshHeader(); + SetStatus($"Renamed {DateTime.Now:h:mm tt}"); + Renamed?.Invoke(moved); + } + + private void RenameBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Enter) { EndRename(commit: true); e.Handled = true; } + else if (e.Key == VirtualKey.Escape) { EndRename(commit: false); e.Handled = true; } + } + + /// Clicking away commits rather than discards — a typed name the user walked away from was + /// still their intent. + private void RenameBox_LostFocus(object sender, RoutedEventArgs e) => EndRename(commit: true); + + // ---- menu actions ---- + + private void Reveal_Click(object sender, RoutedEventArgs e) + { + if (Current == null) return; + var dir = Path.GetDirectoryName(Current.Path); + if (dir == null) return; + var failed = ShellOpen.Try(dir); + if (failed != null) SetStatus($"Couldn't open the folder — {failed.Message}"); + } + + private async void Delete_Click(object sender, RoutedEventArgs e) + { + if (Current == null || Store == null) return; + + var note = Current; + var dialog = new ContentDialog + { + Title = "Delete note", + Content = $"Delete “{note.FileName}”? The file is removed from disk. This can't be undone.", + PrimaryButtonText = "Delete", + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = XamlRoot, + }; + if (await dialog.ShowAsync() != ContentDialogResult.Primary) return; + + // Stop the debounce and the watcher before removing the file, so neither reacts to our own + // delete (an autosave here would put the note straight back). + _autosave.Stop(); + SetDirty(false); + StopWatching(); + + if (!Store.Delete(note)) + { + SetStatus("Couldn't delete this note — it may be open in another program"); + StartWatching(note.Path); + return; + } + + Current = null; + Deleted?.Invoke(note); + } +} diff --git a/src/MandoCode.Desktop/Controls/NoteEditorPane.Sync.cs b/src/MandoCode.Desktop/Controls/NoteEditorPane.Sync.cs new file mode 100644 index 0000000..dc4d6d3 --- /dev/null +++ b/src/MandoCode.Desktop/Controls/NoteEditorPane.Sync.cs @@ -0,0 +1,186 @@ +using MandoCode.Desktop.Services; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Windows.System; + +namespace MandoCode.Desktop.Controls; + +/// +/// Keeping the open note in step with the file underneath it. These are plain files in +/// ~/.mandocode/notes, so this editor is NOT the only thing that writes them: Notepad, VS Code, a sync +/// client, or a git checkout can all change one while it's open. +/// +/// Everything here hangs off one decision — compare CONTENT, not timestamps. _lastSavedText is +/// what this editor believes is on disk, so an incoming change that matches it was ours, one that +/// differs while the buffer is clean can be adopted silently, and one that differs while there are +/// unsaved keystrokes is a genuine conflict only the user can resolve. No path here discards typing. +/// +public sealed partial class NoteEditorPane +{ + private enum Conflict { None, ChangedOnDisk, DeletedOnDisk } + + private FileSystemWatcher? _watcher; + private Conflict _conflict; + private string _conflictDiskText = ""; + + // ---- external changes (Notepad, VS Code, sync, git) ---- + + private void StartWatching(string path) + { + StopWatching(); + try + { + var dir = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return; + + _watcher = new FileSystemWatcher(dir, Path.GetFileName(path)) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.FileName, + }; + _watcher.Changed += OnFileSystemEvent; + _watcher.Created += OnFileSystemEvent; + _watcher.Deleted += OnFileSystemEvent; + _watcher.Renamed += OnFileSystemEvent; + _watcher.EnableRaisingEvents = true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + // No watcher (deleted folder, network path, exhausted handles) just means external edits + // aren't noticed live. Editing still works, so this isn't worth a message. + _watcher = null; + } + } + + private void StopWatching() + { + if (_watcher == null) return; + try + { + _watcher.EnableRaisingEvents = false; + _watcher.Changed -= OnFileSystemEvent; + _watcher.Created -= OnFileSystemEvent; + _watcher.Deleted -= OnFileSystemEvent; + _watcher.Renamed -= OnFileSystemEvent; + _watcher.Dispose(); + } + catch (Exception ex) when (ex is IOException or ObjectDisposedException) { } + _watcher = null; + } + + /// Watcher callbacks arrive on a threadpool thread; everything below touches UI. + private void OnFileSystemEvent(object sender, FileSystemEventArgs e) + => _dispatcher.TryEnqueue(HandleExternalChange); + + private void HandleExternalChange() + { + if (Current == null) return; + + if (!File.Exists(Current.Path)) + { + // Only interesting if we'd lose something. With a clean buffer the note is simply gone, and + // the panel's own refresh will drop the row. + if (_dirty) + ShowConflict(Conflict.DeletedOnDisk, + "This note was deleted while you were typing.", + "Save it back", "Discard my text"); + else + Deleted?.Invoke(Current); + return; + } + + string disk; + try + { + disk = File.ReadAllText(Current.Path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return; // mid-write by someone else; the next event brings the settled content + } + + // Compared in FILE form, since that's what the disk holds. + if (disk == ToFileText(_lastSavedText)) return; // that write was ours + if (disk == ToFileText(Editor.Text)) // converged on the same content + { + _lastSavedText = Editor.Text; + SetDirty(false); + return; + } + + if (!_dirty) + { + // Nothing of ours to lose: adopt it. + var caret = Editor.SelectionStart; + LoadIntoEditor(disk); + Editor.Select(Math.Min(caret, Editor.Text.Length), 0); + SetStatus($"Updated from disk {DateTime.Now:h:mm tt}"); + NotifyStored(); + return; + } + + _conflictDiskText = disk; + ShowConflict(Conflict.ChangedOnDisk, + "This note changed on disk while you had unsaved edits.", + "Use the version on disk", "Keep what I typed"); + } + + private void ShowConflict(Conflict kind, string message, string primary, string secondary) + { + _conflict = kind; + _autosave.Stop(); // no autosave until the user chooses — either write would lose text + ConflictBar.Message = message; + ConflictPrimary.Content = primary; + ConflictSecondary.Content = secondary; + ConflictBar.IsOpen = true; + } + + private void ClearConflict() + { + _conflict = Conflict.None; + _conflictDiskText = ""; + ConflictBar.IsOpen = false; + } + + private void ConflictPrimary_Click(object sender, RoutedEventArgs e) + { + if (Current == null) return; + + switch (_conflict) + { + case Conflict.ChangedOnDisk: + LoadIntoEditor(_conflictDiskText); + SetDirty(false); + SetStatus("Loaded the version from disk"); + ClearConflict(); + NotifyStored(); + break; + + case Conflict.DeletedOnDisk: + ClearConflict(); + SaveNow(force: true); // put the note back, deliberately + StartWatching(Current.Path); + break; + } + } + + private void ConflictSecondary_Click(object sender, RoutedEventArgs e) + { + if (Current == null) return; + + switch (_conflict) + { + case Conflict.ChangedOnDisk: + ClearConflict(); + SaveNow(force: true); // keep mine: overwrite disk, deliberately + break; + + case Conflict.DeletedOnDisk: + var gone = Current; + ClearConflict(); + SetDirty(false); + Deleted?.Invoke(gone); + break; + } + } +} diff --git a/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml b/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml new file mode 100644 index 0000000..0433600 --- /dev/null +++ b/src/MandoCode.Desktop/Controls/NoteEditorPane.xaml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/MandoCode.Desktop/MainWindow.xaml.cs b/src/MandoCode.Desktop/MainWindow.xaml.cs index 53e11e2..0742a79 100644 --- a/src/MandoCode.Desktop/MainWindow.xaml.cs +++ b/src/MandoCode.Desktop/MainWindow.xaml.cs @@ -29,10 +29,16 @@ public sealed partial class MainWindow : Window private readonly TranscriptHtmlBuilder _html; // app-global, stateless formatter private readonly Microsoft.UI.Dispatching.DispatcherQueue _dispatcher; - // Snapshots and History share the one docked column left of the content (Grid.Column 1) and are - // mutually exclusive — opening one swaps out the other without re-sliding the column. - private bool _snapshotsPanelOpen; - private bool _historyPanelOpen; + // Snapshots, History, and Notes share the one docked column left of the content (Grid.Column 1) + // and are mutually exclusive — opening one swaps out the other without re-sliding the column. + // One field rather than a bool per panel: with three of them, a set of bools has states that + // shouldn't exist (two open at once) and every new panel would mean touching every check. + private enum LeftPanel { None, Snapshots, History, Notes } + private LeftPanel _leftPanel = LeftPanel.None; + + private bool SnapshotsPanelOpen => _leftPanel == LeftPanel.Snapshots; + private bool HistoryPanelOpen => _leftPanel == LeftPanel.History; + private bool NotesPanelOpen => _leftPanel == LeftPanel.Notes; // Slide animation state for the docked left column. The width is tweened per-frame off // CompositionTarget.Rendering so the panel glides in/out instead of snapping. Width is always @@ -90,8 +96,14 @@ public MainWindow() var panelState = PanelState.Load(); foreach (var p in panelState.CollapsedSnapshotGroups) _collapsedSnapshotGroups.Add(p); foreach (var p in panelState.CollapsedHistoryGroups) _collapsedHistoryGroups.Add(p); + foreach (var p in panelState.CollapsedNoteGroups ?? new()) _collapsedNoteGroups.Add(p); _snapshotsSeenAt = panelState.SnapshotsSeenAt; _historySeenAt = panelState.HistorySeenAt; + _lastNotePath = panelState.LastNotePath; + _noteModel = panelState.NoteModel; + // The editor writes note content; the panel only lists. One store, handed over once. + NoteEditor.Store = _notes; + WireNotesPanel(); // The first agent. Its whole service graph — AIService, approvals, transcript, token // tracking — belongs to it alone, so opening a second tab can't disturb it. @@ -145,6 +157,9 @@ public MainWindow() private void MainWindow_Closed(object sender, WindowEventArgs args) { SaveWorkspace(); // capture the shape BEFORE teardown starts mutating state + // A note being typed when the window closes must land on disk — autosave runs on a debounce, + // so the last few seconds of typing are still only in the TextBox at this point. + NoteEditor.Shutdown(); foreach (var tab in _tabs) tab.View.Shutdown(); _terminal?.ShutDown(); // kill any ConPTY shells so no processes leak diff --git a/src/MandoCode.Desktop/Services/NoteAssistant.cs b/src/MandoCode.Desktop/Services/NoteAssistant.cs new file mode 100644 index 0000000..f44f3cf --- /dev/null +++ b/src/MandoCode.Desktop/Services/NoteAssistant.cs @@ -0,0 +1,205 @@ +using System.Text; +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.ChatCompletion; +using Microsoft.SemanticKernel.Connectors.Ollama; + +namespace MandoCode.Desktop.Services; + +/// +/// The assistant behind the prompt bar on the notes surfaces. Two modes: asking about the ONE note +/// that's open, and asking about the pad as a whole. +/// +/// It has no tools, and that is the design. Like , this builds a +/// bare Ollama kernel with no plugins, filters, or shared history — so it cannot read or write a +/// single file. "No agent ever touches your note" is therefore true by construction rather than by +/// policy: the only route from a reply into a note is the user pressing Insert or Replace. That's also +/// why it isn't an AIService agent — those exist to change your files, which is the opposite of +/// what a notepad wants, and notes are app-wide while agents belong to a project folder. +/// +/// The note travels in the message, not as a path. Notes live in ~/.mandocode/notes, +/// outside every project root, so no root-scoped file tool could read one anyway. Sending the live +/// buffer means the model always sees the note as it is right now — including edits not yet +/// autosaved — with nothing to fall out of sync. Only the CURRENT message carries the note text; +/// earlier turns in the thread keep just their words, so a long back-and-forth doesn't ship five +/// stale copies of the same note. +/// +public static class NoteAssistant +{ + /// One exchange in a note's (in-memory, non-persisted) thread. + public sealed record Turn(bool FromUser, string Text); + + /// How many notes' titles the pad-wide prompt describes, and how many get their full body. + /// Both are reported to the user by the caller — a silent cap reads as "it looked at everything". + public const int MaxIndexedNotes = 200; + public const int MaxFullNotes = 12; + private const int MaxFullChars = 60_000; + + /// Turns of history sent back. A note isn't a conversation; this is just enough for + /// "shorter" or "now do the same for the second one" to mean something. + private const int ThreadTurns = 8; + + private const string PlainTextRules = + " Write plain text that could be pasted straight into a .txt file: no markdown fences, no " + + "headings made of #, no bold or italics, no bullet characters other than \"- \". Keep it as " + + "short as the question allows."; + + private const string NoteSystemPrompt = + "You are a writing assistant attached to a single plain-text note. The note's current contents " + + "are given in the user's message and are the only source of truth — you have no file access and " + + "cannot change the note yourself. If the user asks you to rewrite, tidy, expand, or continue " + + "it, reply with ONLY the replacement text, no preamble and no explanation, because the user may " + + "put your reply straight into the note. If they ask a question, just answer it. Never invent " + + "content and present it as something the note says." + PlainTextRules; + + private const string PadSystemPrompt = + "You are helping someone search and make sense of their own notes. You are given a list of " + + "their notes (title, date, first line) and the full text of some of them. Answer only from what " + + "you were given: if the answer isn't there, say so plainly and name what you'd need to see — " + + "never guess at the contents of a note you were only shown the title of. Refer to notes by " + + "title so the user can find them." + PlainTextRules; + + /// + /// Streams an answer about the open note. , when non-empty, focuses the + /// request on the selected passage — which is what makes "tighten this" mean the paragraph the user + /// highlighted rather than the whole note. + /// + public static Task AskAboutNoteAsync( + string endpoint, + string model, + string noteTitle, + string noteText, + string? selection, + IReadOnlyList thread, + string question, + Action onDelta, + CancellationToken ct) + { + var message = new StringBuilder(); + message.Append("Note title: ").Append(noteTitle).Append('\n'); + message.Append("--- current contents of the note ---\n"); + message.Append(string.IsNullOrEmpty(noteText) ? "(the note is empty)" : noteText); + message.Append("\n--- end of note ---\n"); + + if (!string.IsNullOrWhiteSpace(selection)) + { + message.Append("The user has this passage selected; act on it rather than the whole note:\n"); + message.Append("--- selection ---\n").Append(selection).Append("\n--- end of selection ---\n"); + } + + message.Append('\n').Append(question.Trim()); + + return StreamAsync(endpoint, model, NoteSystemPrompt, thread, message.ToString(), onDelta, ct); + } + + /// + /// Streams an answer about the whole pad. supplies titles and first + /// lines; is the subset whose bodies are included (the caller picks these — + /// typically whatever the search box is currently matching). + /// + public static Task AskAboutPadAsync( + string endpoint, + string model, + IReadOnlyList indexed, + IReadOnlyList full, + IReadOnlyList thread, + string question, + Action onDelta, + CancellationToken ct) + { + var message = new StringBuilder(); + + message.Append("--- the user's notes (").Append(indexed.Count).Append(" listed) ---\n"); + foreach (var note in indexed.Take(MaxIndexedNotes)) + { + message.Append("- \"").Append(note.Title).Append("\" (").Append(note.GroupLabel) + .Append(", ").Append(note.ModifiedAt.LocalDateTime.ToString("yyyy-MM-dd")).Append(')'); + if (!string.IsNullOrEmpty(note.Preview)) message.Append(" — ").Append(note.Preview); + message.Append('\n'); + } + + var budget = MaxFullChars; + var included = 0; + var bodies = new StringBuilder(); + foreach (var note in full.Take(MaxFullNotes)) + { + if (note.Text.Length == 0) continue; + if (included > 0 && note.Text.Length > budget) break; + + bodies.Append("\n--- full text of \"").Append(note.Title).Append("\" ---\n") + .Append(note.Text).Append('\n'); + budget -= note.Text.Length; + included++; + } + + if (included > 0) + { + message.Append(bodies); + message.Append("--- end of note contents ---\n"); + } + else + { + message.Append("(No note bodies were included — only the list above.)\n"); + } + + message.Append('\n').Append(question.Trim()); + + return StreamAsync(endpoint, model, PadSystemPrompt, thread, message.ToString(), onDelta, ct); + } + + /// How many of would actually have their body sent — so the UI can + /// say what was read instead of implying it saw everything. + public static int CountBodiesSent(IReadOnlyList full) + { + var budget = MaxFullChars; + var included = 0; + foreach (var note in full.Take(MaxFullNotes)) + { + if (note.Text.Length == 0) continue; + if (included > 0 && note.Text.Length > budget) break; + budget -= note.Text.Length; + included++; + } + return included; + } + + /// + /// One streamed round on a throwaway kernel. Stateless by construction: a fresh + /// per call, built from the system prompt, the recent thread, and this + /// message. Deltas arrive on a background thread — the caller marshals. + /// + private static async Task StreamAsync( + string endpoint, + string model, + string systemPrompt, + IReadOnlyList thread, + string message, + Action onDelta, + CancellationToken ct) + { + var kernel = Kernel.CreateBuilder() + .AddOllamaChatCompletion(modelId: model, endpoint: new Uri(endpoint)) + .Build(); + + var chat = kernel.GetRequiredService(); + + var history = new ChatHistory(); + history.AddSystemMessage(systemPrompt); + + foreach (var turn in thread.TakeLast(ThreadTurns)) + { + if (turn.FromUser) history.AddUserMessage(turn.Text); + else history.AddAssistantMessage(turn.Text); + } + + history.AddUserMessage(message); + + // Low temperature: this rewrites the user's own words, so faithful beats inventive. + var settings = new OllamaPromptExecutionSettings { Temperature = 0.3f }; + + await foreach (var chunk in chat.GetStreamingChatMessageContentsAsync(history, settings, kernel, ct)) + { + if (ct.IsCancellationRequested) return; + if (!string.IsNullOrEmpty(chunk.Content)) onDelta(chunk.Content); + } + } +} diff --git a/src/MandoCode.Desktop/Services/NoteEntry.cs b/src/MandoCode.Desktop/Services/NoteEntry.cs new file mode 100644 index 0000000..7ebc7bf --- /dev/null +++ b/src/MandoCode.Desktop/Services/NoteEntry.cs @@ -0,0 +1,58 @@ +namespace MandoCode.Desktop.Services; + +/// +/// One note found on disk. Notes are app-wide — a jot pad, not a project artifact — so unlike a +/// snapshot or an archived conversation this is only a point-in-time reading of a file under +/// . Nothing here is authoritative: the file is. That's what makes a note +/// written in Notepad show up and a note deleted outside the app disappear, with no index to drift. +/// +/// Pure data + display derivations, no UI types: discovery runs off the UI thread. +/// +public sealed record NoteEntry +{ + /// Absolute path to the note file. + public required string Path { get; init; } + + /// + /// Optional folder this note is filed under, relative to — empty for + /// a note sitting loose at the top. It's a plain subfolder name rather than metadata precisely so + /// there's nothing to keep in sync: the filesystem holds the grouping, and re-filing a note by + /// dragging it between folders in Explorer just works. New notes are stamped with the active + /// agent's project folder when there is one, which is what lets the panel group by project + /// without notes being owned by a project. + /// + public required string Group { get; init; } + + public required DateTimeOffset ModifiedAt { get; init; } + public required long Bytes { get; init; } + + /// First non-empty line, trimmed for the card. Empty for an untouched note. + public required string Preview { get; init; } + + /// The note's text as read at discovery, capped by — + /// what search matches against, and what the assistant is given. Not the editor's copy: opening a + /// note always re-reads the file. + public required string Text { get; init; } + + /// File name with extension ("ideas.txt") — a note's identity in its folder. + public string FileName => System.IO.Path.GetFileName(Path); + + /// Card title: the file name without its extension. + public string Title => System.IO.Path.GetFileNameWithoutExtension(Path); + + /// Group heading for the panel. Notes with no folder collect under one heading rather + /// than floating above the groups, so the list has exactly one shape. + public string GroupLabel => string.IsNullOrEmpty(Group) ? "Unfiled" : Group; + + public string TimeLabel => ProjectDisplay.TimeLabel(ModifiedAt); + + /// "empty" / "412 B" / "3.1 KB" — a note's length is most of what tells you whether it's + /// a stub or something you actually wrote. + public string SizeLabel => Bytes switch + { + <= 0 => "empty", + < 1024 => $"{Bytes} B", + < 1024 * 1024 => $"{Bytes / 1024.0:0.#} KB", + _ => $"{Bytes / (1024.0 * 1024.0):0.#} MB", + }; +} diff --git a/src/MandoCode.Desktop/Services/NoteStore.cs b/src/MandoCode.Desktop/Services/NoteStore.cs new file mode 100644 index 0000000..3e7a663 --- /dev/null +++ b/src/MandoCode.Desktop/Services/NoteStore.cs @@ -0,0 +1,311 @@ +namespace MandoCode.Desktop.Services; + +/// +/// The jot pad: plain text files under one app-wide folder, ~/.mandocode/notes. +/// +/// Notes are global on purpose — the same call as snapshots and session history. A note is something +/// you want to write down *now*, which is often between projects or before an agent is even open, so +/// tying a note's existence to a project folder made the feature need permission to be used. What +/// survives of "which project was this about" is a plain SUBFOLDER: a new note is filed under the +/// active agent's folder name when there is one, and sits loose at the top when there isn't. Grouping +/// therefore costs no metadata, can't drift, and is fixed by dragging files around in Explorer. +/// +/// The filesystem is the store. No index, no JSON. That's why the folder lives in +/// ~/.mandocode (beside the CLI's own config.json) rather than in LocalAppData: these +/// are your files, meant to be greppable, syncable, and openable in any editor. Discovery is a walk +/// of one folder plus its immediate subfolders, which is nothing, and in exchange no row can ever +/// point at a file that isn't there. +/// +/// Content is written in exactly one place — NoteEditorPane's autosave, i.e. your own +/// keystrokes. The note assistant has no filesystem tools at all (see ), +/// so nothing it produces can reach a note except through an explicit Insert or Replace. +/// +/// The root is injected rather than hard-coded so the tests drive the real thing against temp folders. +/// +public sealed class NoteStore +{ + /// Extensions treated as notes. .txt is what the app creates; .md is here + /// because half the world's existing notes are markdown and refusing to list them would make the + /// panel lie about the folder. + public static readonly string[] NoteExtensions = { ".txt", ".md" }; + + /// Cap on how much of a note is read into memory at discovery — the panel holds every + /// note's text so search matches bodies without re-reading files per keystroke, and so the + /// assistant can be handed a note without a second read. A longer note still lists and still opens + /// in full; only search and the corpus prompt see a truncated body. + public const int MaxTextBytes = 128 * 1024; + + private const int PreviewChars = 160; + private const string DefaultTitle = "Untitled"; + + /// Where notes live: ~/.mandocode/notes, beside the config file the CLI shares. + public static string DefaultRoot => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".mandocode", "notes"); + + /// The one folder this store owns. + public string Root { get; } + + public NoteStore(string? root = null) => Root = root ?? DefaultRoot; + + /// Raised after a create/rename/delete so the panel can repopulate. Fires on the calling + /// thread. + public event Action? Changed; + + // ---- discovery ---- + + /// + /// Every note in the pad, newest-modified first: files at the top of (ungrouped) + /// plus files one level down (grouped by folder name). Blocking file IO — callers run it off the UI + /// thread. One level only: a jot pad with a folder hierarchy is a filing system, and the search box + /// is a better answer to "where did I put it" than nesting. + /// + public IReadOnlyList Discover() + { + var notes = new List(); + + try + { + if (!Directory.Exists(Root)) return notes; + + foreach (var file in Directory.EnumerateFiles(Root, "*", SearchOption.TopDirectoryOnly)) + if (IsNoteFile(file)) notes.Add(Read(file, group: "")); + + foreach (var dir in Directory.EnumerateDirectories(Root)) + { + var name = Path.GetFileName(dir); + // Dot-folders are somebody else's business (a .git the user put here, editor state). + if (name.StartsWith('.')) continue; + + foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.TopDirectoryOnly)) + if (IsNoteFile(file)) notes.Add(Read(file, group: name)); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // An unreadable pad folder means an empty list, never a broken panel. + } + + return notes.OrderByDescending(n => n.ModifiedAt).ToList(); + } + + private static NoteEntry Read(string path, string group) + { + try + { + var info = new FileInfo(path); + var text = ReadCapped(path); + return new NoteEntry + { + Path = info.FullName, + Group = group, + ModifiedAt = info.LastWriteTime, + Bytes = info.Length, + Preview = Preview(text), + Text = text, + }; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A note held open by another program still deserves a row — list it without a body. + return new NoteEntry + { + Path = path, + Group = group, + ModifiedAt = DateTimeOffset.MinValue, + Bytes = 0, + Preview = "(unreadable right now — open in another program?)", + Text = "", + }; + } + } + + public static bool IsNoteFile(string path) => + NoteExtensions.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase); + + private static string ReadCapped(string path) + { + using var reader = new StreamReader(path); + var buffer = new char[MaxTextBytes]; + var read = reader.ReadBlock(buffer, 0, buffer.Length); + return new string(buffer, 0, read); + } + + // ---- lifecycle ---- + + /// + /// Creates an empty note, filed under when given (the active agent's + /// folder name), creating the pad and the subfolder on first use. A blank or colliding title is + /// resolved rather than rejected — this is reached by clicking New mid-thought, so it must never + /// stop to argue about a name. + /// + public NoteEntry Create(string? title = null, string? group = null) + { + var groupName = SanitizeTitle(group); + var dir = groupName.Length == 0 ? Root : Path.Combine(Root, groupName); + Directory.CreateDirectory(dir); + + var path = UniquePath(dir, SanitizeTitle(title) is { Length: > 0 } t ? t : DefaultTitle, ".txt"); + File.WriteAllText(path, ""); + + var info = new FileInfo(path); + var entry = new NoteEntry + { + Path = info.FullName, + Group = groupName, + ModifiedAt = info.LastWriteTime, + Bytes = 0, + Preview = "", + Text = "", + }; + Changed?.Invoke(); + return entry; + } + + /// + /// Renames a note in place, keeping its extension and its folder. Returns the moved note, or null + /// when the rename was a no-op or impossible (locked file, gone from disk) — the caller keeps + /// showing the old entry rather than losing the note. + /// + public NoteEntry? Rename(NoteEntry note, string newTitle) + { + var clean = SanitizeTitle(newTitle); + if (clean.Length == 0 || clean == note.Title) return null; + + var dir = Path.GetDirectoryName(note.Path)!; + var target = UniquePath(dir, clean, Path.GetExtension(note.Path)); + + try + { + File.Move(note.Path, target); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return null; + } + + var info = new FileInfo(target); + var moved = note with { Path = info.FullName, ModifiedAt = info.LastWriteTime }; + Changed?.Invoke(); + return moved; + } + + /// Deletes a note. Returns false if the file couldn't be removed, so the caller can say so + /// instead of dropping a row that's still on disk. + public bool Delete(NoteEntry note) + { + try + { + if (File.Exists(note.Path)) File.Delete(note.Path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + + Changed?.Invoke(); + return true; + } + + /// Re-reads one note from disk, or null if it's gone. Used after an external change so a + /// single card refreshes without rescanning the pad. + public NoteEntry? Reread(NoteEntry note) + { + try + { + if (!File.Exists(note.Path)) return null; + var info = new FileInfo(note.Path); + var text = ReadCapped(note.Path); + return note with + { + ModifiedAt = info.LastWriteTime, + Bytes = info.Length, + Preview = Preview(text), + Text = text, + }; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return note; + } + } + + // ---- pure helpers (directly unit tested) ---- + + /// + /// Strips what a file name can't hold and collapses whitespace, so a title typed in the rename box + /// ("Q3 ideas: rollout/plan") becomes a legal file name instead of an exception. Leading dots go + /// too — a note called ".secret" would be invisible in its own folder, and a dot-folder is skipped + /// by discovery. + /// + public static string SanitizeTitle(string? title) + { + if (string.IsNullOrWhiteSpace(title)) return ""; + + var cleaned = new string(title + .Select(c => Path.GetInvalidFileNameChars().Contains(c) ? ' ' : c) + .ToArray()); + + cleaned = string.Join(" ", cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + return cleaned.Trim().TrimStart('.').Trim(); + } + + /// First free "<base><ext>", "<base> 2<ext>", … in a folder. + /// Case-insensitive, because Windows won't let "ideas.txt" and "Ideas.txt" coexist and silently + /// overwriting one with the other is the worst outcome a notes app can have. + public static string UniquePath(string dir, string baseName, string ext) + { + var candidate = Path.Combine(dir, baseName + ext); + var n = 1; + while (File.Exists(candidate)) + { + n++; + candidate = Path.Combine(dir, $"{baseName} {n}{ext}"); + } + return candidate; + } + + /// Card subtitle: the first line with anything on it, whitespace-collapsed and capped. + /// The first line is where the gist goes, so it does a title's job on notes still called + /// "Untitled". + public static string Preview(string text) + { + if (string.IsNullOrWhiteSpace(text)) return ""; + + foreach (var raw in text.Split('\n')) + { + var line = raw.Trim().TrimStart('#', '-', '*', ' ').Trim(); + if (line.Length == 0) continue; + return line.Length <= PreviewChars ? line : line[..PreviewChars].TrimEnd() + "…"; + } + return ""; + } + + /// Panel search: file name, group, and note BODY. Matching the body is the whole point — + /// you remember what you wrote, not what you named it. + public static bool Matches(NoteEntry note, string query) + { + if (string.IsNullOrWhiteSpace(query)) return true; + return note.FileName.Contains(query, StringComparison.OrdinalIgnoreCase) + || note.GroupLabel.Contains(query, StringComparison.OrdinalIgnoreCase) + || note.Text.Contains(query, StringComparison.OrdinalIgnoreCase); + } + + /// The matching line from a note's body, so a hit on text the card doesn't show explains + /// itself. Null when the match came from the name or group, or when it's already the preview. + public static string? MatchSnippet(NoteEntry note, string query) + { + if (string.IsNullOrWhiteSpace(query) || note.Text.Length == 0) return null; + + foreach (var raw in note.Text.Split('\n')) + { + if (raw.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + var line = raw.Trim(); + if (line.Length == 0) continue; + if (line == note.Preview) return null; // already visible on the card + return line.Length <= PreviewChars ? line : line[..PreviewChars].TrimEnd() + "…"; + } + } + return null; + } +} diff --git a/src/MandoCode.Desktop/Services/NoteText.cs b/src/MandoCode.Desktop/Services/NoteText.cs new file mode 100644 index 0000000..df9ae08 --- /dev/null +++ b/src/MandoCode.Desktop/Services/NoteText.cs @@ -0,0 +1,36 @@ +namespace MandoCode.Desktop.Services; + +/// +/// Newline handling between a note file and the TextBox that edits it. Extracted from +/// NoteEditorPane (which can't be compiled into the WinUI-free test project) because getting +/// this wrong is invisible in the app and destructive in the file. +/// +/// A WinUI TextBox normalizes every newline to a bare CR on assignment, so Editor.Text is not +/// the string that was handed to it. Two consequences, both of which cost real notes before this +/// existed: +/// +/// Writing Editor.Text straight back out converts a Notepad-authored CRLF note to +/// CR-only, which Notepad then renders as one enormous line. +/// Comparing the original file text against Editor.Text to detect edits reports a +/// difference the moment a note is merely OPENED, so an untouched note gets autosaved — bumping +/// its modified time and logging phantom edits to its shadow. +/// +/// +public static class NoteText +{ + /// The note's own line ending: CRLF if it has any, else LF if it has any, else the + /// platform default for a file with no newline yet to copy. + public static string DetectNewline(string fileText) + { + if (string.IsNullOrEmpty(fileText)) return Environment.NewLine; + if (fileText.Contains("\r\n", StringComparison.Ordinal)) return "\r\n"; + return fileText.Contains('\n') ? "\n" : Environment.NewLine; + } + + /// Editor text → file text: collapse whatever form the control holds newlines in, then + /// write them back out as — the convention the note already used. + public static string ToFileText(string editorText, string newline) => + editorText.Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace('\r', '\n') + .Replace("\n", newline, StringComparison.Ordinal); +} diff --git a/src/MandoCode.Desktop/Services/PanelState.cs b/src/MandoCode.Desktop/Services/PanelState.cs index f893e77..6b88421 100644 --- a/src/MandoCode.Desktop/Services/PanelState.cs +++ b/src/MandoCode.Desktop/Services/PanelState.cs @@ -5,12 +5,18 @@ namespace MandoCode.Desktop.Services; /// Per-panel UI memory: which project groups are folded shut (by project label; empty means /// all expanded), and when the user last opened each panel — the "seen" watermark that makes the /// rail badge an unread count ("new since you last looked") rather than a running total. A null -/// watermark means never opened, so everything currently there counts as new. +/// watermark means never opened, so everything currently there counts as new. +/// is the note the Notes panel had open when it was last closed, so +/// reopening the panel lands back in that note rather than on the list — notes are returned to far +/// more often than snapshots are. public sealed record PanelStateShape( List CollapsedSnapshotGroups, List CollapsedHistoryGroups, DateTimeOffset? SnapshotsSeenAt = null, - DateTimeOffset? HistorySeenAt = null); + DateTimeOffset? HistorySeenAt = null, + List? CollapsedNoteGroups = null, + string? LastNotePath = null, + string? NoteModel = null); /// /// Persists per-panel UI preference — the fold state of the Snapshots and History project groups — @@ -36,7 +42,10 @@ public static PanelStateShape Load() shape.CollapsedSnapshotGroups ?? new(), shape.CollapsedHistoryGroups ?? new(), shape.SnapshotsSeenAt, - shape.HistorySeenAt); + shape.HistorySeenAt, + shape.CollapsedNoteGroups ?? new(), + shape.LastNotePath, + shape.NoteModel); } } catch { /* corrupt/unreadable — start with everything expanded */ }