Skip to content

Commit 79f4ed6

Browse files
committed
feat: complete v0.2.8 — all tasks done, roadmap updated for release
- All 39 Task Master tasks completed (10 deferred to future releases) - ROADMAP.md rewritten: removed shipped v0.2.7 section, added all v0.2.8 tasks organized by category (performance, text shaping, LSP, viewers, bug fixes), updated Known Issues with fixed items checked off - Handover prompt updated for release readiness - Key v0.2.8 features: rendered view perf (AST cache, viewport culling, block height cache), HarfRust text shaping, LSP Phases 1-2, image/PDF viewer tabs, background file loading, per-frame O(N) elimination, terminal CJK wide chars, Windows borderless fix, 10+ bug fixes Made-with: Cursor
1 parent d3ebdd6 commit 79f4ed6

39 files changed

Lines changed: 3414 additions & 469 deletions

Cargo.lock

Lines changed: 315 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ walkdir = "2"
5252
# Git integration (no SSH/HTTPS - we only need local repo status)
5353
git2 = { version = "0.19", default-features = false }
5454

55-
# Icon loading + markdown image rendering (PNG, JPEG, GIF, WebP)
56-
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
55+
# Icon loading + markdown image rendering (PNG, JPEG, GIF, WebP, BMP)
56+
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
5757
font-kit = "0.14.3"
5858

5959
# Internationalization
@@ -97,6 +97,10 @@ poll-promise = { version = "0.3", optional = true }
9797
harfrust = "0.5.2"
9898
unicode-script = "0.5"
9999
unicode-segmentation = "1.11"
100+
unicode-width = "0.1"
101+
102+
# PDF rendering (pure Rust, CPU-based)
103+
hayro = "0.5"
100104

101105
# High-performance memory allocators (platform-specific)
102106
# mimalloc for Windows: faster allocation, reduced fragmentation

ROADMAP.md

Lines changed: 124 additions & 165 deletions
Large diffs are not rendered by default.

docs/ai-context.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Rust (edition 2021) + egui 0.28 markdown editor. Immediate-mode GUI — no retai
1414
## Tech Stack
1515
- **Language:** Rust 2021, egui 0.28 + eframe (immediate-mode GUI)
1616
- **Text:** ropey (rope buffer), comrak (Markdown AST), syntect (syntax highlighting), harfrust (OTL shaping)
17-
- **Terminal:** portable-pty + vte | **VCS:** git2 | **Dialogs:** rfd | **i18n:** rust-i18n | **Hashing:** blake3
17+
- **Terminal:** portable-pty + vte | **VCS:** git2 | **Dialogs:** rfd | **i18n:** rust-i18n | **Hashing:** blake3 | **PDF:** hayro
1818
- **Memory:** mimalloc (Windows), jemalloc (Unix)
1919

2020
## Architecture
@@ -63,6 +63,8 @@ fn process(text: &str) -> Vec<&str> { text.lines().collect() }
6363
- **State:** `Tab` for per-tab, `AppState` for global
6464
- **Errors:** User-facing via `show_toast()`, technical via `log::error!`
6565
- **Large files (>1MB):** Hash-based `is_modified()`, reduced undo stack (10 vs 100), no `original_bytes`
66+
- **Background file loading (≥5MB):** `open_file_smart()` on `FerriteApp` spawns background thread; `Tab.tab_content` (`TabContent::Loading`/`Ready`/`Error`) tracks state; `FileLoadMsg` channel polled in `update()`
67+
- **Per-frame caching:** `Tab.content_version` (u64) gates cached `is_modified()`, `text_stats()`, `needs_cjk_cached()`, `needs_complex_script_cached()` — never scan full content per frame
6668

6769
## Where Things Live (common)
6870

@@ -76,6 +78,7 @@ fn process(text: &str) -> Vec<&str> { text.lines().collect() }
7678
| Modify markdown parsing | `markdown/parser.rs` |
7779
| Modify central panel | `app/central_panel.rs` |
7880
| Add special tab | `state.rs``SpecialTabKind`, `app/central_panel.rs` |
81+
| Add viewer tab | `state.rs``TabKind` variant + state struct, `app/central_panel.rs` → render method |
7982
| Add global/per-tab state | `state.rs``AppState` / `Tab` struct |
8083
| Add i18n string | `locales/en.yaml` + `t!("key")` |
8184
| Mermaid diagrams | `markdown/mermaid/` (flowchart has `types`, `parser`, `layout/`, `render/`) |

docs/current-handover-prompt.md

Lines changed: 20 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -15,69 +15,26 @@
1515
- Prefer Task Master MCP tools over CLI when available.
1616
- Use Context7 MCP when needed.
1717

18-
## Current Task: 35 — LSP Server Activation Based on Open Files
19-
20-
### Task Details
21-
| Field | Value |
22-
|-------|-------|
23-
| **ID** | 35 |
24-
| **Title** | LSP Server Activation Based on Open Files |
25-
| **Complexity** | 8 |
26-
| **Priority** | high |
27-
| **Dependencies** | Tasks 24, 25, 33 — all done |
28-
29-
### Critical Note
30-
**This task is likely already complete.** Task 33 (On-Demand LSP Server Startup) implemented ALL functionality described in Task 35:
31-
- On-demand server start via `sync_active_doc_to_lsp()` in `src/app/file_ops.rs`
32-
- Per-server doc count tracking (`lsp_open_doc_count`) and tab-to-server mapping (`lsp_tab_server`)
33-
- 30-second idle shutdown via `check_lsp_idle_shutdown()`
34-
- `didClose` on tab close via `cleanup_tab_state()` in `src/app/mod.rs`
35-
- All edge cases (multi-tab same lang, rapid switching, crashes, unknown extensions)
36-
37-
**Recommended action:** Review the implementation (see docs below), verify the test scenarios, and mark as done if satisfied.
38-
39-
### Description
40-
Implement on-demand LSP server activation that detects which language servers are needed based on currently open file tabs, starting servers only when matching files are opened and stopping them after idle timeout when the last tab for a language is closed.
41-
42-
### Implementation Details
43-
1. **Tab Activation Detection** (`src/app/file_ops.rs`):
44-
- `sync_active_doc_to_lsp()` checks active tab's extension via `detect_lsp_server_for_path()`
45-
- If server status is `Disconnected` → calls `start_lsp_server_on_demand()`
46-
- If `Starting`/`Initializing` → waits; if `Ready` → sends `didOpen`/`didChange`
47-
48-
2. **Server Lifecycle** (`src/app/file_ops.rs` + `src/app/mod.rs`):
49-
- `lsp_open_doc_count: HashMap<String, usize>` tracks open docs per server
50-
- `lsp_tab_server: HashMap<usize, (PathBuf, String)>` maps tab ID to (path, server_key)
51-
- `cleanup_tab_state()` sends `didClose` on tab close and decrements count
52-
- `check_lsp_idle_shutdown()` stops servers idle ≥ 30s
53-
54-
3. **Extension Mapping** (`src/lsp/detection.rs`):
55-
- `detect_lsp_server_for_path()` maps extensions to server specs
56-
- User overrides applied via `lsp_server_overrides` in settings
57-
58-
### Key Files
59-
| File | Purpose |
60-
|------|---------|
61-
| `src/app/file_ops.rs` | `sync_active_doc_to_lsp()`, `start_lsp_server_on_demand()`, `check_lsp_idle_shutdown()`, `handle_lsp_events()` |
62-
| `src/app/mod.rs` | `FerriteApp` fields (`lsp_tab_server`, `lsp_open_doc_count`, `lsp_idle_since`), `cleanup_tab_state()` |
63-
| `src/lsp/detection.rs` | `detect_lsp_server_for_path()` extension-to-server mapping |
64-
| `src/lsp/manager.rs` | `LspManager` — server spawn, lifecycle, crash recovery |
65-
| `src/state.rs` | `start_lsp_for_workspace()` (now no-op) |
66-
| `docs/technical/lsp/lsp-on-demand-startup.md` | Full feature documentation |
67-
68-
### Test Strategy
69-
1. Fresh workspace: Open Rust tab → server spawns. Switch to Markdown tab → NO server spawn.
70-
2. Tab reactivation: Close Rust tab → idle timer starts. Reopen within 30s → no respawn.
71-
3. Idle shutdown: Close all Rust tabs → server shuts after 30s (log check).
72-
4. Multi-lang: Rust+Python tabs → 2 servers. Close all Rust → 1 server.
73-
5. Multiple tabs same language: Open two Rust tabs → one server. Close one → server stays. Close both → idle shutdown.
74-
6. Unknown extensions: Open .xyz file → no server, no errors.
75-
7. Crash recovery: Kill rust-analyzer → reactivate Rust tab → restarts.
76-
8. Status bar shows 'rust-analyzer: Starting…→Ready'. Toggle `lsp_enabled=false` → no spawn.
18+
## Current Status: v0.2.8 Release Ready
19+
20+
### All Tasks Complete
21+
All 39 Task Master tasks are `done`. 10 tasks are `deferred`:
22+
- Tasks 11-17: Executable Code Blocks → deferred to v0.2.9+
23+
- Tasks 27-28: Alt-Key Menu Bar → deferred to v0.3.0
24+
- Task 38: eframe 0.28→0.31 upgrade → deferred to v0.2.9
25+
26+
### Release Readiness
27+
- ROADMAP.md updated with all 49 tasks cross-referenced
28+
- v0.2.7 section removed from roadmap (in CHANGELOG.md)
29+
- Known Issues section updated (fixed items checked off)
30+
- All documentation in `docs/technical/` up to date
31+
- `docs/index.md` current
32+
33+
### Suggested Next Steps
34+
1. **Cut v0.2.8 release** — all planned work complete; tag and build
35+
2. **Add new tasks** — Use `task-master add-task` or parse a new PRD for v0.2.9
36+
3. **Start Task 38** — If ready to attempt the egui 0.28→0.31 upgrade
7737

7838
## Verification
79-
Before starting:
39+
Before starting any new task:
8040
`cargo build`
81-
82-
## Model Selection
83-
Task complexity 8 → **Default model**

docs/index.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
| [Cursor Position Mapping](./technical/editor/cursor-position-mapping.md) | Raw-to-displayed text position mapping for formatted content editing |
5151
| [Galley Cursor Positioning](./technical/editor/galley-cursor-positioning.md) | Pixel-accurate cursor placement using egui Galley text layout |
5252
| [Undo/Redo System](./technical/editor/undo-redo.md) | Per-tab undo/redo with keyboard shortcuts (Ctrl+Z, Ctrl+Y) |
53+
| [Undo Hash Change Detection](./technical/editor/undo-hash-change-detection.md) | Blake3 hash-based undo snapshot to eliminate per-frame content clones |
5354
| [Find and Replace](./technical/editor/find-replace.md) | Search functionality with regex, match highlighting, replace operations |
5455
| [Go to Line](./technical/editor/go-to-line.md) | Ctrl+G modal dialog for line navigation, viewport centering |
5556
| [Duplicate Line](./technical/editor/duplicate-line.md) | Ctrl+Shift+D line/selection duplication, char-to-byte index handling |
@@ -60,6 +61,8 @@
6061
| [Semantic Minimap](./technical/editor/semantic-minimap.md) | Semantic minimap with clickable heading labels, content type indicators, density bars |
6162
| [Editor Minimap (Legacy)](./technical/editor/minimap.md) | VS Code-style pixel minimap (replaced by semantic minimap) |
6263
| [Search Highlight](./technical/editor/search-highlight.md) | Search-in-files result navigation with transient highlight, auto Raw mode switch |
64+
| [Search Highlight Rendered View](./technical/editor/search-highlight-rendered-view.md) | Rendered view search highlights (incl. tables) and floating panel z-order stacking |
65+
| [Search Highlight Edit Recompute](./technical/editor/search-highlight-edit-recompute.md) | Fix stale search highlights after document edits by recomputing match positions |
6366
| [Syntax Highlighting](./technical/editor/syntax-highlighting.md) | Syntect integration for code block highlighting |
6467
| [Auto-close Brackets](./technical/editor/auto-close-brackets.md) | Auto-pair insertion, selection wrapping, skip-over behavior for brackets/quotes |
6568
| [Bracket Matching](./technical/editor/bracket-matching.md) | Highlight matching brackets and parentheses |
@@ -135,6 +138,8 @@
135138
| [Block-Level Height Cache](./technical/markdown/block-level-height-cache.md) | Per-block blake3-keyed LRU height cache for off-screen block measurement skip |
136139
| [Strict Line Breaks](./technical/markdown/strict-line-breaks.md) | Optional setting treating single newlines as hard `<br>` breaks |
137140
| [Lazy Block Height Estimation](./technical/markdown/lazy-block-height-estimation.md) | Heuristic heights for unmeasured blocks, render budget cap, progressive refinement |
141+
| [Paragraph Trailing Spaces](./technical/markdown/paragraph-trailing-spaces.md) | Fix for trailing spaces lost in plain paragraphs via persistent edit buffer |
142+
| [Rendered Paragraph Block Spacing](./technical/markdown/rendered-paragraph-block-spacing.md) | Trailing space after block paragraphs and code blocks; viewport height alignment |
138143

139144
### Data Viewers
140145

@@ -146,7 +151,10 @@
146151
| [CSV Header Detection](./technical/viewers/csv-header-detection.md) | Auto-detect header rows with heuristics, toggle UI, column alignment |
147152
| [CSV Rainbow Columns](./technical/viewers/csv-rainbow-columns.md) | Subtle alternating column colors using Oklch, status bar toggle |
148153
| [CSV Raw View Caching](./technical/viewers/csv-raw-view-caching.md) | Blake3 hash-guarded raw text cache to eliminate per-frame string allocation |
154+
| [Image Viewer](./technical/viewers/image-viewer.md) | Dedicated image viewer tabs (PNG/JPEG/GIF/WebP/BMP) with zoom and metadata |
155+
| [PDF Viewer](./technical/viewers/pdf-viewer.md) | Read-only PDF viewer tabs with hayro rendering, page navigation, zoom |
149156
| [Tree Viewer](./technical/viewers/tree-viewer.md) | JSON/YAML/TOML tree viewer with inline editing, expand/collapse, path copying |
157+
| [Tree Viewer Caching](./technical/viewers/tree-viewer-caching.md) | Blake3-guarded parse cache and raw text buffer to avoid per-frame work |
150158
| [Live Pipeline](./technical/viewers/live-pipeline.md) | JSON/YAML command piping through shell commands (jq, yq), recent history |
151159
| [Document Export](./technical/viewers/document-export.md) | HTML export with themed CSS, Copy-as-HTML clipboard functionality |
152160

@@ -171,6 +179,7 @@
171179
| [Terminal UI](./technical/terminal/terminal-ui.md) | Terminal panel with tabs, split panes, floating windows, drag-and-drop |
172180
| [Terminal Themes](./technical/terminal/terminal-themes.md) | Terminal color schemes (Solarized, Dracula, Monokai, Nord, etc.) |
173181
| [Terminal Layout](./technical/terminal/terminal-layout.md) | Split pane layouts (horizontal/vertical), grid creation, layout save/load |
182+
| [Terminal CJK Wide Chars](./technical/terminal/terminal-cjk-wide-chars.md) | Double-width CJK character rendering, cursor advancement, selection snapping |
174183

175184
### Productivity Hub
176185

@@ -192,7 +201,8 @@
192201
| [Custom Title Bar](./technical/platform/custom-title-bar.md) | Windows-style custom title bar implementation |
193202
| [Window Resize](./technical/platform/window-resize.md) | Custom resize handles for borderless windows, edge detection |
194203
| [Windows Borderless Window](./technical/platform/windows-borderless-window.md) | Top edge resize fix, fullscreen toggle (F10), title bar button area exclusion |
195-
| [Windows Path Normalization](./technical/platform/windows-path-normalization.md) | Strip Windows `\\?\` prefix from canonicalized paths |
204+
| [Windows Borderless Transparent Fix](./technical/platform/windows-borderless-transparent-fix.md) | Fix rendering offset (black bars) on Intel GPUs via `with_transparent(true)` DWM workaround |
205+
|| [Windows Path Normalization](./technical/platform/windows-path-normalization.md) | Strip Windows `\\?\` prefix from canonicalized paths |
196206
| [Linux Cursor Flicker Fix](./technical/platform/linux-cursor-flicker-fix.md) | Title bar exclusion zone to prevent cursor conflicts with window controls |
197207
| **[Idle Mode Optimization](./technical/platform/idle-mode-optimization.md)** | **Tiered idle repaint system to reduce CPU usage on all platforms** |
198208
| **[SignPath Code Signing](./technical/platform/signpath-code-signing.md)** | **Windows code signing via SignPath for OSS** |
@@ -270,6 +280,13 @@
270280
| **[Mermaid Crate Plan](./mermaid-crate-plan.md)** | **Extract Mermaid renderer as standalone pure-Rust crate** |
271281
| **[Math Support Plan](./math-support-plan.md)** | **v0.4.0 planning: Native LaTeX/TeX math rendering (pure Rust)** |
272282

283+
### Performance
284+
285+
| Document | Description |
286+
|----------|-------------|
287+
| **[Per-Frame Cache Elimination](./technical/performance/per-frame-cache-elimination.md)** | **content_version-based caching to eliminate 7 O(N) per-frame operations for large files** |
288+
| [Background File Loading](./technical/performance/background-file-loading.md) | Background thread loading for 5MB+ files with progress bar, cancellation support |
289+
273290
### Core (Remaining)
274291

275292
| Document | Description |

docs/technical/editor/editor-widget.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,38 @@ pub struct EditorOutput {
8080
}
8181
```
8282

83+
### Scroll Persistence & egui Widget IDs
84+
85+
egui persists `ScrollArea` state (scroll offset) per widget `Id`. To prevent scroll positions leaking across tab switches, every central-panel editor/preview widget ID must be scoped with `tab.id`:
86+
87+
```rust
88+
// ✅ DO: Scope widget ID with tab.id — each tab gets independent scroll state
89+
let editor_widget_id = egui::Id::new("main_editor_raw").with(tab.id);
90+
EditorWidget::new(tab)
91+
.id(editor_widget_id)
92+
.show(ui);
93+
94+
// ✅ DO: MarkdownEditor can inline it (only borrows tab.content, not tab)
95+
MarkdownEditor::new(&mut tab.content)
96+
.id(egui::Id::new("main_editor_rendered").with(tab.id))
97+
.show(ui);
98+
99+
// ❌ DON'T: Fixed ID leaks scroll offset between tabs
100+
EditorWidget::new(tab)
101+
.id(egui::Id::new("main_editor_raw"))
102+
.show(ui);
103+
```
104+
105+
Note: For `EditorWidget`, capture the ID before the builder chain since `EditorWidget::new(tab)` takes a mutable borrow of the entire `Tab`, making `tab.id` inaccessible in the chain.
106+
107+
The four scoped IDs in `src/app/central_panel.rs`:
108+
- `"main_editor_raw"` — Raw mode, single pane
109+
- `"main_editor_rendered"` — Rendered mode, single pane
110+
- `"split_editor_raw"` — Split view, left (raw) pane
111+
- `"split_preview_rendered"` — Split view, right (preview) pane
112+
113+
`FerriteEditor` storage (raw editor) is separately keyed by `tab_id` in a `HashMap` inside `FerriteEditorStorage` (see `src/editor/widget.rs`), so its virtual scrolling is already per-tab independent of the widget ID.
114+
83115
## Dependencies Used
84116

85117
- `egui` - TextEdit::multiline, ScrollArea, FontId
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Search Highlight Recomputation After Document Edits
2+
3+
## Problem
4+
5+
When the user edits a document while the Ctrl+F find panel is open, search highlight rectangles drift out of alignment with actual match positions. The yellow highlight boxes shift horizontally and vertically, no longer overlaying the correct text.
6+
7+
## Root Cause
8+
9+
`FindState.matches` stores search results as byte-position pairs `(start_byte, end_byte)` computed against the document text at search time. When the user inserts or deletes text (e.g., pressing Enter), byte positions of all subsequent matches shift, but `find_matches()` was only called when:
10+
11+
1. The search term changed (debounced via `dialogs.rs`)
12+
2. The find panel was first opened (`find_replace.rs`)
13+
3. After replace operations
14+
15+
No recomputation was triggered by document edits, so stale byte offsets were passed to the highlight renderer each frame.
16+
17+
## Fix
18+
19+
In `src/app/central_panel.rs`, after each editor's content-change detection (`editor_output.changed == true`), if the find panel is active with a non-empty search term, `find_matches()` is re-run against the current `tab.content`.
20+
21+
### Affected View Modes
22+
23+
| View Mode | Detection | Location |
24+
|-----------|-----------|----------|
25+
| Raw | `editor_output.changed` from `EditorWidget` | After tab borrow ends (~line 857) |
26+
| Split | `editor_output.changed` from left raw editor OR `md_editor_output.changed` from right rendered pane | After both panes render (~line 1740) |
27+
| Rendered | `editor_output.changed` from `MarkdownEditor` | After tab borrow ends (~line 1960) |
28+
29+
### Pattern
30+
31+
Each view mode uses a flag variable to capture the change signal inside the mutable `tab` borrow scope, then recomputes after the borrow ends:
32+
33+
```rust
34+
let mut content_changed_in_editor = false;
35+
if let Some(tab) = self.state.active_tab_mut() {
36+
// ... editor rendering ...
37+
if editor_output.changed {
38+
content_changed_in_editor = true;
39+
}
40+
}
41+
// After tab borrow ends:
42+
if content_changed_in_editor
43+
&& self.state.ui.show_find_replace
44+
&& !self.state.ui.find_state.search_term.is_empty()
45+
{
46+
if let Some(content) = self.state.active_tab().map(|t| t.content.clone()) {
47+
self.state.ui.find_state.find_matches(&content);
48+
}
49+
}
50+
```
51+
52+
## Performance
53+
54+
- **Only runs on actual edits** — gated by `editor_output.changed`, not per-frame
55+
- **O(N) text clone + regex search** — same cost as the existing debounced search path
56+
- `find_matches()` uses compiled regex internally, which is efficient for typical document sizes
57+
- For 100K+ line files, the search is still user-initiated (typing causes the edit), keeping within the O(N) user-initiated tier from the performance rules
58+
59+
## Key Files
60+
61+
| File | Role |
62+
|------|------|
63+
| `src/app/central_panel.rs` | Recomputation trigger after edit detection in all 3 view modes |
64+
| `src/editor/find_replace.rs` | `FindState::find_matches()` — the search engine |
65+
| `src/editor/widget.rs` | `SearchHighlights` struct passed to `FerriteEditor` |
66+
| `src/editor/ferrite/search.rs` | `set_search_matches()` — converts byte ranges to `SearchMatch` with pre-computed line numbers |
67+
| `src/editor/ferrite/highlights.rs` | `render_search_highlights()` — paints highlight rectangles |

0 commit comments

Comments
 (0)