From 33ad78f7ae9ed5f52c223681087555a3a01c0413 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Sun, 29 Mar 2026 12:29:41 +0300 Subject: [PATCH 1/4] CHANGELOG.md --- CHANGELOG.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a451c95..0a9eb19c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,10 @@ ### Features -* **Multi-LSP Server Support**: Configure multiple LSP servers per language (e.g., pylsp + pyright for Python). Servers are routed by feature using `only_features`/`except_features` filters, completions are merged from all eligible servers, and diagnostics are tracked per-server. Per-server status is shown in the status bar. +* **Multi-LSP Server Support**: Configure multiple LSP servers per language (e.g., pylsp + pyright for Python). Servers are routed by feature using `only_features`/`except_features` filters, completions are merged from all eligible servers, and diagnostics are tracked per-server. Per-server status is shown in the status bar (#971). * **Per-Language Editor Settings**: `line_wrap`, `wrap_column`, `page_view`, and `page_width` can now be configured per-language. For example, wrap Markdown at 80 columns while keeping code unwrapped (#1371). -* **Settings Deep Search**: Search now walks into Map entries, TextList items, and nested JSON values. Searching "python" finds the "python" key in language/LSP maps. Results show hierarchical breadcrumbs (e.g., "Languages > python") and auto-focus the matching entry. - * **Diff Chunk Navigation Plugin**: New built-in plugin for navigating between diff chunks, merging git and saved-diff sources. ### Improvements @@ -18,6 +16,8 @@ * **Settings UI Overhaul**: Modernized visual design with wider modal (160 cols), rounded corner borders, Nerd Font category icons, styled `[✓]` toggles, and reverse-video key hints. Keyboard navigation rewritten: Tab cycles sequentially through all fields and buttons, composite controls (Map, ObjectArray, TextList) support internal navigation, entry dialogs have section headers with explicit field ordering, PageDown/PageUp work in the main panel, and TextList edits auto-accept on navigation. Focus indicator now highlights per-row in composite controls. +* **Settings Deep Search**: Also in the Settings UI: Search now walks into Map entries, TextList items, and nested JSON values. Searching "python" finds the "python" key in language/LSP maps. Results show hierarchical breadcrumbs (e.g., "Languages > python") and auto-focus the matching entry. + * **Per-Language Workspace Root Detection**: New `root_markers` field on LSP server configs. The editor walks upward from the file's directory looking for configured markers (e.g., `Cargo.toml`, `package.json`), replacing the old cwd-based root (#1360). * **Page View Mode**: "Compose" mode renamed to "Page View". Can now auto-activate per language via `page_view: true` in language config. Old keybinding names continue to work. @@ -58,8 +58,6 @@ * Fixed large file recovery saving the entire file as individual chunks instead of using the recovery format. -* Fixed `file://` URI handling silently dropping `..` path components, which could cause LSP root mismatches. - * Fixed read-only detection for files not owned by the current user (now checks effective uid/gid instead of file mode bits). ## 0.2.18 From aca15bac5ab16436033ef1db94809c6e95655ea4 Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Sun, 29 Mar 2026 12:36:18 +0300 Subject: [PATCH 2/4] Add Page View commands to command palette Toggle Page View and Set Page Width actions existed but had no command palette entries, making them undiscoverable. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/fresh-editor/locales/en.json | 4 ++++ crates/fresh-editor/src/input/commands.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/fresh-editor/locales/en.json b/crates/fresh-editor/locales/en.json index 433a82e1f..bfff5a0ad 100644 --- a/crates/fresh-editor/locales/en.json +++ b/crates/fresh-editor/locales/en.json @@ -646,6 +646,10 @@ "cmd.toggle_fold_desc": "Collapse or expand the fold at the cursor", "cmd.toggle_line_wrap": "Toggle Line Wrap", "cmd.toggle_line_wrap_desc": "Enable or disable line wrapping in the editor", + "cmd.toggle_page_view": "Toggle Page View", + "cmd.toggle_page_view_desc": "Toggle narrow page view (compose) mode for the current buffer", + "cmd.set_page_width": "Set Page Width", + "cmd.set_page_width_desc": "Set the narrow page width for page view mode", "cmd.toggle_read_only": "Toggle Read-Only Mode", "cmd.toggle_read_only_desc": "Enable or disable read-only mode for the current buffer", "cmd.toggle_maximize_split": "Toggle Maximize Split", diff --git a/crates/fresh-editor/src/input/commands.rs b/crates/fresh-editor/src/input/commands.rs index aedbaf926..f159449e5 100644 --- a/crates/fresh-editor/src/input/commands.rs +++ b/crates/fresh-editor/src/input/commands.rs @@ -802,6 +802,20 @@ static COMMAND_DEFS: &[CommandDef] = &[ contexts: &[Normal], custom_contexts: &[], }, + CommandDef { + name_key: "cmd.toggle_page_view", + desc_key: "cmd.toggle_page_view_desc", + action: || Action::TogglePageView, + contexts: &[Normal], + custom_contexts: &[], + }, + CommandDef { + name_key: "cmd.set_page_width", + desc_key: "cmd.set_page_width_desc", + action: || Action::SetPageWidth, + contexts: &[Normal], + custom_contexts: &[], + }, CommandDef { name_key: "cmd.toggle_read_only", desc_key: "cmd.toggle_read_only_desc", From c80aa95b36598a7b37ad4115e46c4810a54c868a Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Sun, 29 Mar 2026 12:40:25 +0300 Subject: [PATCH 3/4] Search command palette by description, not just name Previously description matching was a fallback only used when no name matches were found. Now descriptions are always searched, with results sorted: name matches first, then description matches, then by score. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/input/command_registry.rs | 99 +++++++++---------- .../fresh-editor/tests/e2e/command_palette.rs | 21 ++++ 2 files changed, 67 insertions(+), 53 deletions(-) diff --git a/crates/fresh-editor/src/input/command_registry.rs b/crates/fresh-editor/src/input/command_registry.rs index 2813d1a67..4b8a7d490 100644 --- a/crates/fresh-editor/src/input/command_registry.rs +++ b/crates/fresh-editor/src/input/command_registry.rs @@ -253,9 +253,10 @@ impl CommandRegistry { (suggestion, history_pos, score) }; - // First, try to match by name only + // Match by name or description // Commands with unmet custom contexts are completely hidden - let mut suggestions: Vec<(Suggestion, Option, i32)> = commands + // match_kind: 0 = name match, 1 = description match + let mut suggestions: Vec<(Suggestion, Option, i32, u8)> = commands .iter() .filter(|cmd| is_visible(cmd)) .filter_map(|cmd| { @@ -263,73 +264,65 @@ impl CommandRegistry { let name_result = fuzzy_match(query, &localized_name); if name_result.matched { let localized_desc = cmd.get_localized_description(); - Some(make_suggestion( - cmd, - name_result.score, - localized_name, - localized_desc, - )) - } else { - None - } - }) - .collect(); - - // If no name matches found, try description matching as a fallback - if suggestions.is_empty() && !query.is_empty() { - suggestions = commands - .iter() - .filter(|cmd| is_visible(cmd)) - .filter_map(|cmd| { + let (suggestion, hist, score) = + make_suggestion(cmd, name_result.score, localized_name, localized_desc); + Some((suggestion, hist, score, 0)) + } else if !query.is_empty() { let localized_desc = cmd.get_localized_description(); let desc_result = fuzzy_match(query, &localized_desc); if desc_result.matched { - let localized_name = cmd.get_localized_name(); - // Description matches get reduced score - Some(make_suggestion( - cmd, - desc_result.score.saturating_sub(50), - localized_name, - localized_desc, - )) + let (suggestion, hist, score) = + make_suggestion(cmd, desc_result.score, localized_name, localized_desc); + Some((suggestion, hist, score, 1)) } else { None } - }) - .collect(); - } + } else { + None + } + }) + .collect(); // Sort by: // 1. Disabled status (enabled first) - // 2. Fuzzy match score (higher is better) - only when query is not empty - // 3. History position (recent first, then never-used alphabetically) + // 2. Match kind (name matches before description matches) - only when query is not empty + // 3. Fuzzy match score (higher is better) - only when query is not empty + // 4. History position (recent first, then never-used alphabetically) let has_query = !query.is_empty(); - suggestions.sort_by(|(a, a_hist, a_score), (b, b_hist, b_score)| { - // First sort by disabled status - match a.disabled.cmp(&b.disabled) { - std::cmp::Ordering::Equal => {} - other => return other, - } - - // When there's a query, sort by fuzzy score (higher is better) - if has_query { - match b_score.cmp(a_score) { + suggestions.sort_by( + |(a, a_hist, a_score, a_kind), (b, b_hist, b_score, b_kind)| { + // First sort by disabled status + match a.disabled.cmp(&b.disabled) { std::cmp::Ordering::Equal => {} other => return other, } - } - // Then sort by history position (lower = more recent = better) - match (a_hist, b_hist) { - (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos), - (Some(_), None) => std::cmp::Ordering::Less, // In history beats not in history - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.text.cmp(&b.text), // Alphabetical for never-used commands - } - }); + if has_query { + // Name matches before description matches + match a_kind.cmp(b_kind) { + std::cmp::Ordering::Equal => {} + other => return other, + } + + // Within the same kind, sort by fuzzy score (higher is better) + match b_score.cmp(a_score) { + std::cmp::Ordering::Equal => {} + other => return other, + } + } + + // Then sort by history position (lower = more recent = better) + match (a_hist, b_hist) { + (Some(a_pos), Some(b_pos)) => a_pos.cmp(b_pos), + (Some(_), None) => std::cmp::Ordering::Less, // In history beats not in history + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.text.cmp(&b.text), // Alphabetical for never-used commands + } + }, + ); // Extract just the suggestions - suggestions.into_iter().map(|(s, _, _)| s).collect() + suggestions.into_iter().map(|(s, _, _, _)| s).collect() } /// Get count of registered plugin commands diff --git a/crates/fresh-editor/tests/e2e/command_palette.rs b/crates/fresh-editor/tests/e2e/command_palette.rs index ffa47da0b..82c4d9ca7 100644 --- a/crates/fresh-editor/tests/e2e/command_palette.rs +++ b/crates/fresh-editor/tests/e2e/command_palette.rs @@ -1047,3 +1047,24 @@ fn test_command_palette_select_cursor_style() { .wait_for_screen_contains("Cursor style changed") .unwrap(); } + +/// Test that command palette searches descriptions, not just names +#[test] +fn test_command_palette_description_search() { + use crossterm::event::{KeyCode, KeyModifiers}; + let mut harness = EditorTestHarness::new(100, 24).unwrap(); + + // Trigger the command palette + harness + .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL) + .unwrap(); + + // Type "narrow" which only appears in the description of page view commands, + // not in their names + harness.type_text("narrow").unwrap(); + harness.render().unwrap(); + + // Should find commands whose descriptions match + harness.assert_screen_contains("Toggle Page View"); + harness.assert_screen_contains("Set Page Width"); +} From 3d8f89fe19e4819f7903e0ee2f22bf76de88dcfa Mon Sep 17 00:00:00 2001 From: Noam Lewis Date: Sun, 29 Mar 2026 12:43:45 +0300 Subject: [PATCH 4/4] Default page view width to 80 columns Add global editor.page_width setting (default 80) that per-language page_width falls back to. Previously page_width defaulted to None (viewport width), making page view indistinguishable from normal editing on most screens. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fresh-editor/plugins/config-schema.json | 14 +++- .../fresh-editor/src/app/buffer_management.rs | 2 +- crates/fresh-editor/src/app/view_actions.rs | 3 +- crates/fresh-editor/src/config.rs | 14 +++- crates/fresh-editor/src/partial_config.rs | 4 ++ .../fresh-editor/tests/e2e/line_wrapping.rs | 65 +++++++++++++++++++ 6 files changed, 98 insertions(+), 4 deletions(-) diff --git a/crates/fresh-editor/plugins/config-schema.json b/crates/fresh-editor/plugins/config-schema.json index 09f62355f..0f8f872f8 100644 --- a/crates/fresh-editor/plugins/config-schema.json +++ b/crates/fresh-editor/plugins/config-schema.json @@ -35,6 +35,7 @@ "line_wrap": true, "wrap_indent": true, "wrap_column": null, + "page_width": 80, "syntax_highlighting": true, "show_menu_bar": true, "menu_bar_mnemonics": true, @@ -273,6 +274,17 @@ "default": null, "x-section": "Display" }, + "page_width": { + "description": "Width of the page in page view mode (in columns).\nControls the content width when page view is active, with centering margins.\nDefaults to 80. Set to `null` to use the full viewport width.", + "type": [ + "integer", + "null" + ], + "format": "uint", + "minimum": 0, + "default": 80, + "x-section": "Display" + }, "syntax_highlighting": { "description": "Enable syntax highlighting for code files", "type": "boolean", @@ -957,7 +969,7 @@ "default": null }, "page_width": { - "description": "Width of the page in page view mode (in columns).\nControls the content width when page view is active, with centering margins.\nIf not specified (`null`), uses the viewport width.", + "description": "Width of the page in page view mode (in columns).\nControls the content width when page view is active, with centering margins.\nIf not specified (`null`), falls back to the global `editor.page_width` setting.", "type": [ "integer", "null" diff --git a/crates/fresh-editor/src/app/buffer_management.rs b/crates/fresh-editor/src/app/buffer_management.rs index 81a5a5514..ec9967917 100644 --- a/crates/fresh-editor/src/app/buffer_management.rs +++ b/crates/fresh-editor/src/app/buffer_management.rs @@ -48,7 +48,7 @@ impl Editor { let state = self.buffers.get(&buffer_id)?; let lang_config = self.config.languages.get(&state.language)?; if lang_config.page_view == Some(true) { - Some(lang_config.page_width) + Some(lang_config.page_width.or(self.config.editor.page_width)) } else { None } diff --git a/crates/fresh-editor/src/app/view_actions.rs b/crates/fresh-editor/src/app/view_actions.rs index c9d78e4a6..d31231feb 100644 --- a/crates/fresh-editor/src/app/view_actions.rs +++ b/crates/fresh-editor/src/app/view_actions.rs @@ -20,7 +20,8 @@ impl Editor { .buffers .get(&active_buffer) .and_then(|s| self.config.languages.get(&s.language)) - .and_then(|lc| lc.page_width); + .and_then(|lc| lc.page_width) + .or(self.config.editor.page_width); let view_mode = { let current = self diff --git a/crates/fresh-editor/src/config.rs b/crates/fresh-editor/src/config.rs index 40253de98..3008892cf 100644 --- a/crates/fresh-editor/src/config.rs +++ b/crates/fresh-editor/src/config.rs @@ -578,6 +578,13 @@ pub struct EditorConfig { #[schemars(extend("x-section" = "Display"))] pub wrap_column: Option, + /// Width of the page in page view mode (in columns). + /// Controls the content width when page view is active, with centering margins. + /// Defaults to 80. Set to `null` to use the full viewport width. + #[serde(default = "default_page_width")] + #[schemars(extend("x-section" = "Display"))] + pub page_width: Option, + /// Enable syntax highlighting for code files #[serde(default = "default_true")] #[schemars(extend("x-section" = "Display"))] @@ -1125,6 +1132,7 @@ impl Default for EditorConfig { line_wrap: true, wrap_indent: true, wrap_column: None, + page_width: default_page_width(), highlight_timeout_ms: default_highlight_timeout(), snapshot_interval: default_snapshot_interval(), large_file_threshold_bytes: default_large_file_threshold(), @@ -1426,6 +1434,10 @@ fn default_on_save_timeout() -> u64 { 10000 } +fn default_page_width() -> Option { + Some(80) +} + /// Language-specific configuration #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[schemars(extend("x-display-field" = "/grammar"))] @@ -1495,7 +1507,7 @@ pub struct LanguageConfig { /// Width of the page in page view mode (in columns). /// Controls the content width when page view is active, with centering margins. - /// If not specified (`null`), uses the viewport width. + /// If not specified (`null`), falls back to the global `editor.page_width` setting. #[serde(default)] pub page_width: Option, diff --git a/crates/fresh-editor/src/partial_config.rs b/crates/fresh-editor/src/partial_config.rs index 4d03eac92..23bff646b 100644 --- a/crates/fresh-editor/src/partial_config.rs +++ b/crates/fresh-editor/src/partial_config.rs @@ -150,6 +150,7 @@ pub struct PartialEditorConfig { pub line_wrap: Option, pub wrap_indent: Option, pub wrap_column: Option>, + pub page_width: Option>, pub highlight_timeout_ms: Option, pub snapshot_interval: Option, pub large_file_threshold_bytes: Option, @@ -218,6 +219,7 @@ impl Merge for PartialEditorConfig { self.line_wrap.merge_from(&other.line_wrap); self.wrap_indent.merge_from(&other.wrap_indent); self.wrap_column.merge_from(&other.wrap_column); + self.page_width.merge_from(&other.page_width); self.highlight_timeout_ms .merge_from(&other.highlight_timeout_ms); self.snapshot_interval.merge_from(&other.snapshot_interval); @@ -481,6 +483,7 @@ impl From<&crate::config::EditorConfig> for PartialEditorConfig { line_wrap: Some(cfg.line_wrap), wrap_indent: Some(cfg.wrap_indent), wrap_column: Some(cfg.wrap_column), + page_width: Some(cfg.page_width), highlight_timeout_ms: Some(cfg.highlight_timeout_ms), snapshot_interval: Some(cfg.snapshot_interval), large_file_threshold_bytes: Some(cfg.large_file_threshold_bytes), @@ -557,6 +560,7 @@ impl PartialEditorConfig { line_wrap: self.line_wrap.unwrap_or(defaults.line_wrap), wrap_indent: self.wrap_indent.unwrap_or(defaults.wrap_indent), wrap_column: self.wrap_column.unwrap_or(defaults.wrap_column), + page_width: self.page_width.unwrap_or(defaults.page_width), highlight_timeout_ms: self .highlight_timeout_ms .unwrap_or(defaults.highlight_timeout_ms), diff --git a/crates/fresh-editor/tests/e2e/line_wrapping.rs b/crates/fresh-editor/tests/e2e/line_wrapping.rs index 32c476878..075632fe3 100644 --- a/crates/fresh-editor/tests/e2e/line_wrapping.rs +++ b/crates/fresh-editor/tests/e2e/line_wrapping.rs @@ -2387,3 +2387,68 @@ fn test_page_view_not_activated_for_other_languages() { screen ); } + +/// Test that page view defaults to 80 columns on a wide terminal +#[test] +fn test_page_view_default_width_80() { + use crossterm::event::{KeyCode, KeyModifiers}; + + // Use a wide terminal so 80-col centering is visible + let mut harness = EditorTestHarness::new(160, 24).unwrap(); + + // Create a file with content that fits within 80 columns + let content = "Hello from page view test"; + let fixture = + crate::common::fixtures::TestFixture::new("test_default_width.txt", content).unwrap(); + harness.open_file(&fixture.path).unwrap(); + harness.render().unwrap(); + + // Toggle page view on + harness + .send_key(KeyCode::Char('p'), KeyModifiers::CONTROL) + .unwrap(); + harness.type_text("Toggle Page View").unwrap(); + harness + .send_key(KeyCode::Enter, KeyModifiers::NONE) + .unwrap(); + harness.render().unwrap(); + + let screen = harness.screen_to_string(); + + // Find the row containing our content + let mut content_row = String::new(); + let height = 24u16; + for y in 0..height { + let row = harness.get_row_text(y); + if row.contains("Hello from") { + content_row = row; + break; + } + } + assert!( + !content_row.is_empty(), + "Should find a row with content\nScreen:\n{}", + screen + ); + + let trimmed = content_row.trim(); + + // Content should be at most 80 chars wide + assert!( + trimmed.len() <= 80, + "Page view content should be at most 80 columns wide, got {} chars: '{}'\nScreen:\n{}", + trimmed.len(), + trimmed, + screen + ); + + // There should be left margin (centering) since terminal is 160 cols + let leading_spaces = content_row.len() - content_row.trim_start().len(); + assert!( + leading_spaces >= 30, + "Expected centering margin (>=30 cols) on 160-col terminal, got {} leading spaces\nRow: '{}'\nScreen:\n{}", + leading_spaces, + content_row, + screen + ); +}