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/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 */ } } 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 */ }