@fresh-editor/fresh-editor 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1743,7 +1743,30 @@ function applyCursorLineOverlay(panel: 'diff'): void {
1743
1743
  });
1744
1744
  }
1745
1745
 
1746
- function review_refresh() { refreshMagitData(); }
1746
+ function review_refresh() {
1747
+ // Synchronously acknowledge the keypress before kicking off the
1748
+ // async `git status` + `git diff`. Those calls take long enough on
1749
+ // a non-trivial repo that, without this immediate status update,
1750
+ // the sticky-header totals visibly lag the new content: users
1751
+ // press `r`, see the old `+N / -M`, conclude the keystroke was
1752
+ // dropped, and press `r` again — which then "appears" to work
1753
+ // because the first refresh has by then landed. See #2036.
1754
+ //
1755
+ // In range mode the refresh is intentionally a no-op for
1756
+ // working-tree edits (the diff is always between two refs); the
1757
+ // range-specific message explains that up front so the user
1758
+ // doesn't think `r` is broken when their unstaged changes don't
1759
+ // show up.
1760
+ if (state.mode === 'range' && state.range) {
1761
+ editor.setStatus(
1762
+ editor.t("status.refreshing_range", { range: state.range.label }) ||
1763
+ `Refreshing ${state.range.label}... (working tree not included)`
1764
+ );
1765
+ } else {
1766
+ editor.setStatus(editor.t("status.refreshing") || "Refreshing review diff...");
1767
+ }
1768
+ void refreshMagitData();
1769
+ }
1747
1770
  registerHandler("review_refresh", review_refresh);
1748
1771
 
1749
1772
  // --- Cursor-driven navigation ---
@@ -3163,13 +3186,21 @@ function updateReviewStatus(): void {
3163
3186
  if (state.groupId === null) return;
3164
3187
  const total = state.hunkHeaderRows.length;
3165
3188
  const current = currentGlobalHunkIndex();
3189
+ // Range reviews fundamentally don't include working-tree edits; the
3190
+ // suffix makes that visible from the status bar at all times rather
3191
+ // than only flashing past during a refresh. Without it users hit `r`,
3192
+ // see their unsaved changes don't appear, and conclude the refresh is
3193
+ // broken (#2036).
3194
+ const rangeNote = state.mode === 'range' && state.range
3195
+ ? ` · ${editor.t("status.working_tree_not_included") || "working tree not included"}`
3196
+ : '';
3166
3197
  if (current !== null) {
3167
3198
  editor.setStatus(editor.t("status.review_summary_indexed", {
3168
3199
  current: String(current),
3169
3200
  count: String(total),
3170
- }));
3201
+ }) + rangeNote);
3171
3202
  } else {
3172
- editor.setStatus(editor.t("status.review_summary", { count: String(total) }));
3203
+ editor.setStatus(editor.t("status.review_summary", { count: String(total) }) + rangeNote);
3173
3204
  }
3174
3205
  }
3175
3206
 
@@ -4640,13 +4671,121 @@ async function review_branch_refresh(): Promise<void> {
4640
4671
  }
4641
4672
  registerHandler("review_branch_refresh", review_branch_refresh);
4642
4673
 
4643
- /** Enter: focus the detail panel (so the user can scroll/click within it). */
4674
+ /** Is the detail panel the currently-focused buffer? */
4675
+ function isReviewBranchDetailFocused(): boolean {
4676
+ return (
4677
+ branchState.detailBufferId !== null &&
4678
+ editor.getActiveBufferId() === branchState.detailBufferId
4679
+ );
4680
+ }
4681
+
4682
+ /** The currently-selected commit in the log panel, or null. */
4683
+ function selectedReviewBranchCommit(): GitCommit | null {
4684
+ if (branchState.commits.length === 0) return null;
4685
+ const i = Math.max(
4686
+ 0,
4687
+ Math.min(branchState.selectedIndex, branchState.commits.length - 1),
4688
+ );
4689
+ return branchState.commits[i] ?? null;
4690
+ }
4691
+
4692
+ /**
4693
+ * Enter: on the log panel jumps focus into the detail panel; on the detail
4694
+ * panel opens the file at the cursor position at the selected commit (if any).
4695
+ */
4644
4696
  function review_branch_enter(): void {
4645
4697
  if (branchState.groupId === null) return;
4698
+ if (isReviewBranchDetailFocused()) {
4699
+ void review_branch_detail_open_file();
4700
+ return;
4701
+ }
4646
4702
  editor.focusBufferGroupPanel(branchState.groupId, "detail");
4647
4703
  }
4648
4704
  registerHandler("review_branch_enter", review_branch_enter);
4649
4705
 
4706
+ /**
4707
+ * Open the file at the cursor's `(file, line)` text-properties at the
4708
+ * currently-selected commit, in a read-only virtual buffer. Mirrors the
4709
+ * git-log plugin's `git_log_detail_open_file` so users get the same
4710
+ * drill-down from the review-branch detail panel.
4711
+ */
4712
+ async function review_branch_detail_open_file(): Promise<void> {
4713
+ if (branchState.detailBufferId === null) return;
4714
+ const commit = selectedReviewBranchCommit();
4715
+ if (!commit) return;
4716
+
4717
+ const props = editor.getTextPropertiesAtCursor(branchState.detailBufferId);
4718
+ if (props.length === 0) {
4719
+ editor.setStatus(editor.t("status.move_to_diff"));
4720
+ return;
4721
+ }
4722
+ const file = props[0].file as string | undefined;
4723
+ const line = (props[0].line as number | undefined) ?? 1;
4724
+ if (!file) {
4725
+ editor.setStatus(editor.t("status.move_to_diff_with_context"));
4726
+ return;
4727
+ }
4728
+
4729
+ editor.setStatus(
4730
+ editor.t("status.file_loading", { file, hash: commit.shortHash }),
4731
+ );
4732
+ const result = await editor.spawnProcess("git", [
4733
+ "show",
4734
+ `${commit.hash}:${file}`,
4735
+ ]);
4736
+ if (result.exit_code !== 0) {
4737
+ editor.setStatus(
4738
+ editor.t("status.file_not_found", { file, hash: commit.shortHash }),
4739
+ );
4740
+ return;
4741
+ }
4742
+
4743
+ const lines = result.stdout.split("\n");
4744
+ const entries: TextPropertyEntry[] = lines.map((l, i) => ({
4745
+ text: l + (i < lines.length - 1 ? "\n" : ""),
4746
+ properties: { type: "content", line: i + 1 },
4747
+ }));
4748
+
4749
+ // `*<hash>:<path>*` matches the virtual-name convention the host uses
4750
+ // to detect syntax from the trailing filename's extension.
4751
+ const name = `*${commit.shortHash}:${file}*`;
4752
+ const view = await editor.createVirtualBuffer({
4753
+ name,
4754
+ mode: "review-branch-file-view",
4755
+ readOnly: true,
4756
+ editingDisabled: true,
4757
+ showLineNumbers: true,
4758
+ entries,
4759
+ });
4760
+ if (view) {
4761
+ const byte = await editor.getLineStartPosition(Math.max(0, line - 1));
4762
+ if (byte !== null) editor.setBufferCursor(view.bufferId, byte);
4763
+ editor.setStatus(
4764
+ editor.t("status.file_view_ready", {
4765
+ file,
4766
+ hash: commit.shortHash,
4767
+ line: String(line),
4768
+ }),
4769
+ );
4770
+ } else {
4771
+ editor.setStatus(editor.t("status.failed_open_file", { file }));
4772
+ }
4773
+ }
4774
+ registerHandler(
4775
+ "review_branch_detail_open_file",
4776
+ review_branch_detail_open_file,
4777
+ );
4778
+
4779
+ /** Tab: toggle focus between the log and detail panels. */
4780
+ function review_branch_tab(): void {
4781
+ if (branchState.groupId === null) return;
4782
+ editor.focusBufferGroupPanel(
4783
+ branchState.groupId,
4784
+ isReviewBranchDetailFocused() ? "log" : "detail",
4785
+ );
4786
+ }
4787
+ registerHandler("review_branch_tab", review_branch_tab);
4788
+
4650
4789
  /** q/Escape: focus-back from detail, or close when already on log. */
4651
4790
  function review_branch_close_or_back(): void {
4652
4791
  if (branchState.groupId === null) return;
@@ -4683,9 +4822,11 @@ editor.defineMode(
4683
4822
  // from the Normal keymap via `inheritNormalBindings: true`.
4684
4823
  ["k", "move_up"],
4685
4824
  ["j", "move_down"],
4686
- // Enter: focus the right-hand detail panel.
4825
+ // Enter: from the log, focus the detail panel; from the detail
4826
+ // panel, open the file at the cursor at the selected commit.
4687
4827
  ["Return", "review_branch_enter"],
4688
- ["Tab", "review_branch_enter"],
4828
+ // Tab: toggle focus between the log and detail panels.
4829
+ ["Tab", "review_branch_tab"],
4689
4830
  ["r", "review_branch_refresh"],
4690
4831
  ["q", "review_branch_close_or_back"],
4691
4832
  ["Escape", "review_branch_close_or_back"],
@@ -4695,6 +4836,32 @@ editor.defineMode(
4695
4836
  true, // inheritNormalBindings — PageUp/PageDown/arrows/Home/End come from Normal
4696
4837
  );
4697
4838
 
4839
+ /** Close the file-view virtual buffer opened from the review-branch detail panel. */
4840
+ function review_branch_file_view_close(): void {
4841
+ const id = editor.getActiveBufferId();
4842
+ if (id) editor.closeBuffer(id);
4843
+ }
4844
+ registerHandler("review_branch_file_view_close", review_branch_file_view_close);
4845
+
4846
+ // Mode for the read-only "git show <hash>:<file>" buffer opened from the
4847
+ // review-branch detail panel. Mirrors git-log's `git-log-file-view`:
4848
+ // q/Escape close the view, j/k alias Up/Down, and all other Normal
4849
+ // bindings (arrows, PageUp/Down, Home/End, Ctrl+C copy) are inherited so
4850
+ // unbound keys don't fall through to edit actions and trip the
4851
+ // `editing_disabled` status message (see #566).
4852
+ editor.defineMode(
4853
+ "review-branch-file-view",
4854
+ [
4855
+ ["k", "move_up"],
4856
+ ["j", "move_down"],
4857
+ ["q", "review_branch_file_view_close"],
4858
+ ["Escape", "review_branch_file_view_close"],
4859
+ ],
4860
+ true, // read-only
4861
+ false, // allow_text_input
4862
+ true, // inherit Normal-context bindings for unbound keys
4863
+ );
4864
+
4698
4865
  // Register Modes and Commands
4699
4866
  editor.registerCommand("%cmd.review_diff", "%cmd.review_diff_desc", "start_review_diff", null);
4700
4867
  editor.registerCommand("%cmd.review_branch", "%cmd.review_branch_desc", "start_review_branch", null);
@@ -103,6 +103,7 @@
103
103
  "auto_save_enabled": false,
104
104
  "auto_save_interval_secs": 30,
105
105
  "hot_exit": true,
106
+ "confirm_quit": false,
106
107
  "restore_previous_session": true,
107
108
  "skip_session_restore_when_files_passed": true,
108
109
  "auto_create_empty_buffer_on_last_buffer_close": true,
@@ -134,7 +135,10 @@
134
135
  "preview_tabs": true,
135
136
  "side": "left",
136
137
  "auto_open_on_last_buffer_close": true,
137
- "follow_active_buffer": false
138
+ "follow_active_buffer": false,
139
+ "compact_directories": true,
140
+ "tree_indicator_collapsed": ">",
141
+ "tree_indicator_expanded": "▼"
138
142
  }
139
143
  },
140
144
  "file_browser": {
@@ -652,6 +656,12 @@
652
656
  "default": true,
653
657
  "x-section": "Recovery"
654
658
  },
659
+ "confirm_quit": {
660
+ "description": "Whether to confirm before quitting Fresh. When enabled, pressing\nthe quit shortcut surfaces a confirmation prompt even when no\nbuffers are modified. Useful for users who frequently hit\n`Ctrl+Q` by mistake. Independent of the unsaved-changes prompt,\nwhich always fires regardless of this setting.\nDefault: false",
661
+ "type": "boolean",
662
+ "default": false,
663
+ "x-section": "Startup"
664
+ },
655
665
  "restore_previous_session": {
656
666
  "description": "Whether to auto-open previously opened files (session restore) when\nstarting Fresh in a directory. When enabled (the default), tabs,\nsplits, cursor positions and the file explorer state are restored\nfrom the last clean exit in the same working directory. When\ndisabled, Fresh starts with a clean workspace. The workspace file\non disk is still written on exit, so re-enabling this setting picks\nup whatever state was saved at the most recent clean exit. The\n`--no-restore` CLI flag is a stronger override: it skips both\nrestoring and saving the workspace.\nDefault: true",
657
667
  "type": "boolean",
@@ -793,7 +803,8 @@
793
803
  "{messages}"
794
804
  ],
795
805
  "x-section": "Status Bar",
796
- "x-dual-list-sibling": "/editor/status_bar/right"
806
+ "x-dual-list-sibling": "/editor/status_bar/right",
807
+ "x-dynamically-extendable-status-bar-elements": true
797
808
  },
798
809
  "right": {
799
810
  "description": "Elements shown on the right side of the status bar.\nDefault: [\"{line_ending}\", \"{encoding}\", \"{language}\", \"{lsp}\", \"{warnings}\", \"{update}\", \"{palette}\"]",
@@ -811,7 +822,8 @@
811
822
  "{palette}"
812
823
  ],
813
824
  "x-section": "Status Bar",
814
- "x-dual-list-sibling": "/editor/status_bar/left"
825
+ "x-dual-list-sibling": "/editor/status_bar/left",
826
+ "x-dynamically-extendable-status-bar-elements": true
815
827
  }
816
828
  }
817
829
  },
@@ -958,6 +970,21 @@
958
970
  "description": "When the file explorer sidebar is open, automatically expand the\ntree and highlight the file that corresponds to the active buffer\nwhenever you switch tabs. Set to `true` to keep the explorer\nselection in sync with the active tab.\nDefault: false",
959
971
  "type": "boolean",
960
972
  "default": false
973
+ },
974
+ "compact_directories": {
975
+ "description": "Render single-child directory chains on a single line, e.g.\n`src/main/java/com/example`. Only applies when each intermediate\ndirectory in the chain is expanded and has exactly one visible\nchild that is itself a directory. Mirrors VSCode's\n`explorer.compactFolders`.\nDefault: true",
976
+ "type": "boolean",
977
+ "default": true
978
+ },
979
+ "tree_indicator_collapsed": {
980
+ "description": "Symbol shown next to a collapsed (closed) directory in the file\nexplorer tree. A short string (single character recommended).\nA trailing space is added automatically during rendering; the\nrenderer pads narrower indicators so collapsed/expanded rows align.\nDefault: \">\"",
981
+ "type": "string",
982
+ "default": ">"
983
+ },
984
+ "tree_indicator_expanded": {
985
+ "description": "Symbol shown next to an expanded (open) directory in the file\nexplorer tree. A short string (single character recommended).\nA trailing space is added automatically during rendering; the\nrenderer pads narrower indicators so collapsed/expanded rows align.\nDefault: \"▼\"",
986
+ "type": "string",
987
+ "default": "▼"
961
988
  }
962
989
  }
963
990
  },
@@ -1643,6 +1670,10 @@
1643
1670
  "null"
1644
1671
  ],
1645
1672
  "readOnly": true
1673
+ },
1674
+ "settings": {
1675
+ "description": "Plugin-specific settings. The shape is defined by each plugin's\n`<plugin_name>.schema.json` sidecar file; the host stores the value as\nuntyped JSON so a malformed plugin schema can't poison the rest of the\nconfig. Plugins read this via `editor.getPluginConfig()` and the\nSettings UI renders it as a sub-category under \"Plugin Settings\".",
1676
+ "readOnly": true
1646
1677
  }
1647
1678
  },
1648
1679
  "x-display-field": "/enabled"
@@ -1586,18 +1586,20 @@ async function dashboardShowOrFocus() {
1586
1586
  registerHandler("dashboardShowOrFocus", dashboardShowOrFocus);
1587
1587
 
1588
1588
  // Auto-open resolution: the session override (set via the exported
1589
- // plugin API from init.ts) wins over the user config. We read from
1590
- // getUserConfig (raw file) rather than getConfig because unknown
1591
- // fields are dropped when the Config struct reserializes. Default
1592
- // is true.
1589
+ // plugin API from init.ts) wins over the user-configured value, which
1590
+ // comes from the typed plugin-config field declared below. The field
1591
+ // shows up in the Settings UI under "Plugin Settings → dashboard".
1592
+ editor.defineConfigBoolean("autoOpen", {
1593
+ default: true,
1594
+ description: "Show the dashboard automatically when Fresh starts with no real files open.",
1595
+ });
1596
+
1593
1597
  let autoOpenOverride: boolean | null = null;
1594
1598
 
1595
1599
  function autoOpenEnabled(): boolean {
1596
1600
  if (autoOpenOverride !== null) return autoOpenOverride;
1597
- const cfg = editor.getUserConfig() as Record<string, unknown> | null;
1598
- const plugins = cfg?.plugins as Record<string, unknown> | undefined;
1599
- const dashboard = plugins?.dashboard as Record<string, unknown> | undefined;
1600
- return dashboard?.["auto-open"] !== false;
1601
+ const cfg = (editor.getPluginConfig() ?? {}) as { autoOpen?: boolean };
1602
+ return cfg.autoOpen !== false;
1601
1603
  }
1602
1604
 
1603
1605
  function shouldShowDashboard(): boolean {
@@ -1801,12 +1803,14 @@ editor.exportPluginApi("dashboard", {
1801
1803
  // `plugins.dashboard.enabled` is true in the resolved config — so the
1802
1804
  // standard settings UI is the single enable/disable surface.
1803
1805
  //
1804
- // If the plugin loads mid-session (user toggles it on in Settings),
1805
- // the `ready` hook has already fired, so we also run an immediate
1806
- // check. At startup the `listBuffers().length > 0` guard keeps us
1807
- // dormant until the workspace has actually restored: plugins load
1808
- // before restore, and opening a buffer here would race with the
1809
- // restore and leave a stray Dashboard tab even when real files exist.
1806
+ // Auto-open is driven exclusively by the `ready` hook (and the
1807
+ // `buffer_closed` handler for the last-tab-closed case). We
1808
+ // deliberately do NOT auto-open at module load: dashboard.ts loads
1809
+ // during the startup plugin batch, *before* the user's init.ts has
1810
+ // been evaluated, so an immediate auto-open would race
1811
+ // `setAutoOpen(false)` and dismiss the user's preference. Users who
1812
+ // hot-load the plugin mid-session (toggle on in Settings) get the
1813
+ // dashboard via the "Show Dashboard" command in the palette.
1810
1814
  editor.on("ready", "dashboardOnReady");
1811
1815
  editor.on("buffer_closed", "dashboardOnBufferClosed");
1812
1816
  editor.on("viewport_changed", "dashboardOnViewportChanged");
@@ -1820,7 +1824,3 @@ editor.registerCommand(
1820
1824
  "Open the dashboard, or bring it to the front if it's already open",
1821
1825
  "dashboardShowOrFocus",
1822
1826
  );
1823
-
1824
- if (editor.listBuffers().length > 0 && shouldShowDashboard()) {
1825
- openDashboard();
1826
- }
@@ -0,0 +1,168 @@
1
+ /// <reference path="./lib/fresh.d.ts" />
2
+
3
+ /**
4
+ * Environment Manager
5
+ *
6
+ * Detects a project's environment manager (Python venv, direnv, mise) and
7
+ * activates it by handing core an activation **snippet** via `editor.setEnv`.
8
+ * Core captures the resulting environment on the active backend (local / SSH)
9
+ * and applies it to every editor-spawned process — language servers,
10
+ * formatters, `spawnProcess`.
11
+ *
12
+ * Detection is passive (reads files only). Activation runs repo-controlled
13
+ * code, so it is gated on Workspace Trust: the plugin only calls `setEnv` when
14
+ * `editor.workspaceTrustLevel() === "trusted"` (and core enforces the same).
15
+ *
16
+ * Freshness: one-shot spawns re-capture automatically when the env inputs
17
+ * change (core's cache is keyed on them). A long-running language server has
18
+ * its env fixed at spawn, so to pick up a changed `.envrc`/`mise.toml` the
19
+ * user runs **Env: Reload**, which re-captures and restarts servers. (Auto
20
+ * file-watching is intentionally not wired yet.)
21
+ */
22
+
23
+ const editor = getEditor();
24
+
25
+ const STATUS_TOKEN = "env";
26
+
27
+ interface Detected {
28
+ /** Short label for the status pill, e.g. ".venv" / "direnv" / "mise". */
29
+ name: string;
30
+ /** The activation snippet handed to `editor.setEnv`. */
31
+ snippet: string;
32
+ }
33
+
34
+ function fileExists(p: string): boolean {
35
+ try {
36
+ return editor.fileExists(p);
37
+ } catch (_e) {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Detect the environment in the current workspace and return its activation
44
+ * snippet, or null if none. These are auto-detected default snippets; direnv
45
+ * and mise need their exporters (they're prompt-hook driven), venv sources its
46
+ * activate script, and anything else is a pure login shell / user snippet.
47
+ */
48
+ function detect(): Detected | null {
49
+ const cwd = editor.getCwd();
50
+ if (!cwd) return null;
51
+
52
+ for (const name of [".venv", "venv"]) {
53
+ const dir = editor.pathJoin(cwd, name);
54
+ if (
55
+ fileExists(editor.pathJoin(dir, "bin", "python")) ||
56
+ fileExists(editor.pathJoin(dir, "bin", "python3")) ||
57
+ fileExists(editor.pathJoin(dir, "Scripts", "python.exe"))
58
+ ) {
59
+ return { name, snippet: `source ${editor.pathJoin(dir, "bin", "activate")}` };
60
+ }
61
+ }
62
+
63
+ if (fileExists(editor.pathJoin(cwd, ".envrc"))) {
64
+ return { name: "direnv", snippet: `eval "$(direnv export bash)"` };
65
+ }
66
+
67
+ for (const name of ["mise.toml", ".mise.toml", ".tool-versions"]) {
68
+ if (fileExists(editor.pathJoin(cwd, name))) {
69
+ return { name: "mise", snippet: `eval "$(mise env -s bash)"` };
70
+ }
71
+ }
72
+
73
+ return null;
74
+ }
75
+
76
+ function isTrusted(): boolean {
77
+ return editor.workspaceTrustLevel() === "trusted";
78
+ }
79
+
80
+ // === Commands ===
81
+
82
+ /** Activate (or, when already active, reload) the detected environment. */
83
+ function activate(): void {
84
+ if (!isTrusted()) {
85
+ editor.setStatus(
86
+ "Workspace not trusted — run “Workspace Trust: Trust This Folder” to activate the environment",
87
+ );
88
+ return;
89
+ }
90
+ const det = detect();
91
+ if (!det) {
92
+ editor.setStatus("No environment manager detected in this project");
93
+ return;
94
+ }
95
+ // Core captures `snippet` on the active backend and applies it to every
96
+ // spawn; it restarts so language servers re-spawn under the fresh env.
97
+ editor.setEnv(det.snippet, editor.getCwd());
98
+ editor.setStatus(
99
+ `${editor.envActive() ? "Reloading" : "Activating"} ${det.name} environment…`,
100
+ );
101
+ }
102
+ registerHandler("env_activate_handler", activate);
103
+
104
+ function useSystem(): void {
105
+ editor.clearEnv();
106
+ editor.setStatus("Environment deactivated — using the system environment");
107
+ }
108
+ registerHandler("env_use_system_handler", useSystem);
109
+
110
+ function showStatus(): void {
111
+ const det = detect();
112
+ const trust = editor.workspaceTrustLevel() || "unavailable";
113
+ if (editor.envActive()) {
114
+ editor.setStatus(`Environment active${det ? ` (${det.name})` : ""}`);
115
+ } else if (det) {
116
+ editor.setStatus(
117
+ `Detected ${det.name} (trust: ${trust}). Run “Env: Activate” to use it.`,
118
+ );
119
+ } else {
120
+ editor.setStatus(`No environment detected (trust: ${trust})`);
121
+ }
122
+ }
123
+ registerHandler("env_status_handler", showStatus);
124
+
125
+ editor.registerCommand(
126
+ "env_activate",
127
+ "Env: Activate Detected Environment (venv / direnv / mise)",
128
+ "env_activate_handler",
129
+ );
130
+ editor.registerCommand(
131
+ "env_reload",
132
+ "Env: Reload Environment (re-capture after .envrc/mise.toml change)",
133
+ "env_activate_handler",
134
+ );
135
+ editor.registerCommand(
136
+ "env_use_system",
137
+ "Env: Use System (Deactivate Environment)",
138
+ "env_use_system_handler",
139
+ );
140
+ editor.registerCommand(
141
+ "env_status",
142
+ "Env: Show Environment Status",
143
+ "env_status_handler",
144
+ );
145
+
146
+ // === Status pill (opt-in to a user's status-bar layout) ===
147
+
148
+ function refreshStatus(): void {
149
+ const bufferId = editor.getActiveBufferId();
150
+ if (bufferId === 0) return;
151
+ const det = detect();
152
+ let value: string;
153
+ if (editor.envActive()) {
154
+ value = det ? `${det.name} ✓` : "active";
155
+ } else {
156
+ value = det ? `${det.name}${isTrusted() ? "" : " (locked)"}` : "system";
157
+ }
158
+ editor.setStatusBarValue(bufferId, STATUS_TOKEN, value);
159
+ }
160
+
161
+ editor.registerStatusBarElement(STATUS_TOKEN, "Environment");
162
+
163
+ registerHandler("env_refresh_status", refreshStatus);
164
+ for (const event of ["buffer_activated", "after_file_open", "focus_gained"]) {
165
+ editor.on(event, "env_refresh_status");
166
+ }
167
+
168
+ refreshStatus();
package/plugins/flash.ts CHANGED
@@ -33,7 +33,24 @@ const VTEXT_PREFIX = "flash-";
33
33
  // the closest jump targets get the most comfortable keys. All
34
34
  // lowercase: case-sensitive matching keeps the label letter from also
35
35
  // being a valid pattern continuation, which matters for the skip rule.
36
- const LABEL_POOL = "asdfghjklqwertyuiopzxcvbnm";
36
+ editor.defineConfigString("labelPool", {
37
+ default: "asdfghjklqwertyuiopzxcvbnm",
38
+ description: "Characters used as jump labels, in comfort order. Labels are assigned to matches by distance from the cursor, so leftmost characters here land on the nearest matches.",
39
+ });
40
+ editor.defineConfigBoolean("skipRule", {
41
+ default: true,
42
+ description: "Skip a label character if it is also the next character after a match — prevents ambiguity between extending the search pattern and jumping.",
43
+ });
44
+
45
+ function flashSettings(): { labelPool: string; skipRule: boolean } {
46
+ const cfg = (editor.getPluginConfig() ?? {}) as { labelPool?: string; skipRule?: boolean };
47
+ return {
48
+ labelPool: cfg.labelPool && cfg.labelPool.length > 0
49
+ ? cfg.labelPool
50
+ : "asdfghjklqwertyuiopzxcvbnm",
51
+ skipRule: cfg.skipRule ?? true,
52
+ };
53
+ }
37
54
 
38
55
  interface Match {
39
56
  /** Byte offset where the match starts in its buffer. */
@@ -334,9 +351,10 @@ function assignLabels(
334
351
  prevLabelByKey: Map<string, string>,
335
352
  ): Match[] {
336
353
  if (matches.length === 0) return matches;
337
- const skip = buildSkipSet(matches, views, emptyPattern);
354
+ const { labelPool, skipRule } = flashSettings();
355
+ const skip = skipRule ? buildSkipSet(matches, views, emptyPattern) : new Set<string>();
338
356
  const remaining = new Set<string>();
339
- for (const c of LABEL_POOL) if (!skip.has(c)) remaining.add(c);
357
+ for (const c of labelPool) if (!skip.has(c)) remaining.add(c);
340
358
 
341
359
  const sorted = sortMatches(matches, startSplitId, startCursor);
342
360
 
@@ -353,7 +371,7 @@ function assignLabels(
353
371
  // matches in distance order. Iterate the pool in its native
354
372
  // (comfort-ranked) order so home-row letters go to nearest matches.
355
373
  const orderedRemaining: string[] = [];
356
- for (const c of LABEL_POOL) if (remaining.has(c)) orderedRemaining.push(c);
374
+ for (const c of labelPool) if (remaining.has(c)) orderedRemaining.push(c);
357
375
  let next = 0;
358
376
  for (const m of sorted) {
359
377
  if (m.label) continue;
@@ -87,10 +87,13 @@ const blameState: BlameState = {
87
87
  // Color Definitions for Header Styling
88
88
  // =============================================================================
89
89
 
90
- const colors = {
91
- headerFg: [0, 0, 0] as [number, number, number], // Black text
92
- headerBg: [200, 200, 200] as [number, number, number], // Light gray background
93
- };
90
+ // Blame headers are rendered via `addVirtualLine`, which accepts theme
91
+ // keys directly so we don't expose colors as plugin settings. Themes
92
+ // drive the look; if a theme lacks specific blame keys, these fall
93
+ // through to the editor's status-bar palette which is what every theme
94
+ // defines.
95
+ const HEADER_FG_KEY = "ui.status_bar_fg";
96
+ const HEADER_BG_KEY = "ui.status_bar_bg";
94
97
 
95
98
  // =============================================================================
96
99
  // Mode Definition
@@ -373,7 +376,8 @@ function addBlameHeaders(): void {
373
376
  // Clear existing headers first
374
377
  editor.clearVirtualTextNamespace(blameState.bufferId, BLAME_NAMESPACE);
375
378
 
376
- // Add a virtual line above each block
379
+ // Add a virtual line above each block. Pass theme keys so the headers
380
+ // restyle automatically when the user switches themes.
377
381
  for (const block of blameState.blocks) {
378
382
  const headerText = formatBlockHeader(block);
379
383
 
@@ -381,7 +385,7 @@ function addBlameHeaders(): void {
381
385
  blameState.bufferId,
382
386
  block.startByte, // anchor position
383
387
  headerText, // text content
384
- { fg: colors.headerFg, bg: colors.headerBg }, // colors (RGB tuples; passing theme key strings would also work)
388
+ { fg: HEADER_FG_KEY, bg: HEADER_BG_KEY },
385
389
  true, // above (LineAbove)
386
390
  BLAME_NAMESPACE, // namespace for bulk removal
387
391
  0 // priority