konoma 0.19.0

Terminal file browser built for AI pair-programming — full-screen previews (Markdown, images, PDF, CSV), git suite, and an agent-watch mode that follows your AI's edits (macOS and Linux)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
use super::*;

impl App {
    /// Arrange the links in decorated Markdown lines to "show label only", build `md_items` (links +
    /// task checkboxes), and return the line list with the focused item rendered inverted (called just
    /// before rendering). Since tui-markdown outputs the "label (URL)" form, `collapse_links` folds the
    /// accompanying URL (the URL is the hidden destination) and styles the label as a link plus (when
    /// configured) an icon. Checkbox markers are recognized by their dedicated style (`is_task_span`).
    /// Test-only equivalent of the production path (`ensure_md_cache` + `md_slice`) over caller-
    /// supplied lines: collapse links, rebuild `md_items`, and invert the focused item. Shares
    /// `build_md_items` / `invert_focused_line` with the cache path so the two cannot drift.
    /// Post-process decorated Markdown lines just before caching/rendering: collapse the `label
    /// (URL)` links tui-markdown emits, then (when `ui.md_autolink`) auto-link bare URLs/emails, then
    /// (when `ui.md_emoji`) convert `:shortcode:` emoji. Shared by the cache build (`ensure_md_cache`)
    /// and the test-only decorate path so link/emoji collection cannot drift between them. Emoji runs
    /// last so width-affecting substitutions are reflected in the caller's reflow/`row_prefix`.
    pub(super) fn postprocess_md(
        &self,
        lines: Vec<Line<'static>>,
    ) -> (Vec<Line<'static>>, Vec<String>) {
        let (lines, targets) = collapse_links(lines, self.cfg.ui.icons);
        let (lines, targets) = if self.cfg.ui.md_autolink {
            autolink_bare_urls(lines, targets)
        } else {
            (lines, targets)
        };
        let lines = if self.cfg.ui.md_emoji {
            substitute_emoji(lines)
        } else {
            lines
        };
        (lines, targets)
    }

    #[cfg(test)]
    pub fn decorate_md_items(&mut self, lines: Vec<Line<'static>>) -> Vec<Line<'static>> {
        let (lines, targets) = self.postprocess_md(lines);
        let items = build_md_items(&lines, &targets, &[]);
        // フォーカス添字を範囲内にクランプ。
        match self.tab.focused_item {
            Some(_) if items.is_empty() => self.tab.focused_item = None,
            Some(f) if f >= items.len() => self.tab.focused_item = Some(items.len() - 1),
            _ => {}
        }
        self.md_items = items;

        let Some(target_idx) = self.tab.focused_item else {
            return lines;
        };
        let (target_line, whole_line) = {
            let it = &self.md_items[target_idx];
            (it.line, matches!(it.kind, MdItemKind::CodeBlock))
        };
        let first_on_line = self.md_items.partition_point(|x| x.line < target_line);
        let ordinal = target_idx - first_on_line;
        lines
            .into_iter()
            .enumerate()
            .map(|(li, line)| {
                if li == target_line {
                    invert_focused_line(&line, ordinal, whole_line)
                } else {
                    line
                }
            })
            .collect()
    }

    /// Move the item focus of the Markdown preview (dir: +1=next / -1=previous, cyclic over links and
    /// checkboxes in document order). If the focused line is off-screen, scroll until it is visible.
    pub fn md_focus_move(&mut self, dir: i32) {
        if self.md_items.is_empty() {
            return;
        }
        let n = self.md_items.len() as i32;
        let next = match self.tab.focused_item {
            Some(f) => (f as i32 + dir).rem_euclid(n),
            None if dir >= 0 => 0,
            None => n - 1,
        } as usize;
        if self.tab.focused_item != Some(next) {
            // フォーカスが移ったらインライン図のズーム/パンは初期化(前の図の状態を持ち越さない)。
            self.tab.fence_zoom = 1.0;
            self.tab.fence_center = (0.5, 0.5);
        }
        self.tab.focused_item = Some(next);
        // フォーカス行を表示範囲に収める。preview_scroll は**表示行(折返し後)**の座標系
        // (描画側が para.line_count でクランプしている)なので、アイテムの論理行も
        // 表示行へ変換してから比較する(論理行のままだと折返しで乖離し、画面外の
        // フォーカスに追従しない — 2026-07-08 ユーザー報告)。
        let line = self.md_items[next].line;
        let (top, mut height) = self.md_visual_span(line);
        // フェンス図: キャプションだけでなく**図ブロック全体**(キャプション+予約行+下マージン)を
        // 可視域へ入れる(図が下に見切れたまま「フォーカスしたのに見えない」を防ぐ=ユーザー要望)。
        let block_end = if let MdItemKind::MermaidFence { ordinal } = self.md_items[next].kind {
            // 図は予約行を持ち本文行として並んでいないので、placement の行数から終端を求める。
            self.mermaid_placement(ordinal)
                .map(|(pl, pr)| pl + pr as usize) // 下マージン行(placement.line + rows)
                .unwrap_or(line)
        } else {
            // コードブロック / 開いた <details> は複数行にわたる。ヘッダ行だけを可視域に入れると
            // 下端に貼り付いて中身が見えないので、ブロック全体を入れる(ユーザー要望)。
            self.md_item_block_end(next)
        };
        if block_end > line {
            let (bt, bh) = self.md_visual_span(block_end);
            height = (bt + bh).saturating_sub(top).max(height);
        }
        let vh = self.tab.preview_viewport.max(1) as usize;
        let scroll = self.tab.preview_scroll as usize;
        if height >= vh || top < scroll {
            // ブロックが画面より大きい(先頭を出す) or 上に見切れている。
            self.tab.preview_scroll = top as u16;
        } else if top + height > scroll + vh {
            self.tab.preview_scroll = (top + height).saturating_sub(vh) as u16;
        }
    }

    /// Last decorated line belonging to the focused item's **block**.
    ///
    /// Tab focus lands on a single line — a code block's header, a `<details>` summary — but the thing
    /// the user wants to look at continues below it. Ensuring only the focused line is on screen parks
    /// a block at the bottom edge with its contents cut off. Everything that spans more than one line
    /// reports its real extent here so `md_focus_move` can bring the whole block into view.
    fn md_item_block_end(&self, idx: usize) -> usize {
        let Some(item) = self.md_items.get(idx) else {
            return 0;
        };
        let start = item.line;
        let Some(cache) = self.md_cache.as_ref() else {
            return start;
        };
        // 続きの行かどうかは、レンダラが引く「左の帯」で判定する(コードは `▎`、details 本文は `▏`)。
        // 行の見た目そのものを見るので、ソースを再解釈せずに済む。
        let belongs: fn(&ratatui::text::Line<'_>) -> bool = match item.kind {
            MdItemKind::CodeBlock => crate::preview::markdown::is_code_line,
            MdItemKind::Details { .. } => crate::preview::markdown::is_details_body_line,
            // リンク/チェックボックスは1行。フェンス図は予約行を持つので下で placement から求める。
            _ => return start,
        };
        let mut end = start;
        for (i, line) in cache.lines.iter().enumerate().skip(start + 1) {
            if !belongs(line) {
                break;
            }
            end = i;
        }
        end
    }

    #[cfg(test)]
    pub fn md_item_block_end_for_test(&self, idx: usize) -> usize {
        self.md_item_block_end(idx)
    }
    #[cfg(test)]
    pub fn md_item_line_for_test(&self, idx: usize) -> usize {
        self.md_items[idx].line
    }
    #[cfg(test)]
    pub fn md_visual_span_for_test(&self, line: usize) -> (usize, usize) {
        self.md_visual_span(line)
    }
    #[cfg(test)]
    pub fn preview_viewport_for_test(&self) -> u16 {
        self.tab.preview_viewport
    }

    /// (line, rows) of the inline mermaid placement whose **source ordinal** is `ordinal`
    /// (placements carry it in `fence_ord`; counting rendered placements would drift as soon as
    /// an earlier fence has no placement — loading or degraded to text).
    pub(super) fn mermaid_placement(&self, ordinal: usize) -> Option<(usize, u16)> {
        let cache = self.md_cache.as_ref()?;
        cache
            .images
            .iter()
            .find(|p| p.fence_ord == Some(ordinal))
            .map(|p| (p.line, p.rows))
    }

    /// Ordinal of the focused inline mermaid diagram, if the focus is on one (border cue in the renderer).
    pub fn focused_mermaid_ordinal(&self) -> Option<usize> {
        let f = self.tab.focused_item?;
        match self.md_items.get(f)?.kind {
            MdItemKind::MermaidFence { ordinal } => Some(ordinal),
            _ => None,
        }
    }

    /// Visual (post-wrap) row offset of decorated line `line` plus its own wrapped height.
    /// With wrap off this is just (line, 1). Reads the cached per-line reflow prefix sums
    /// (ratatui's own `line_count`, computed once at cache build), so the numbers match what
    /// the renderer draws exactly — and the lookup is O(1) instead of re-flowing the document.
    pub(crate) fn md_visual_span(&self, line: usize) -> (usize, usize) {
        if !self.cfg.ui.wrap {
            return (line, 1);
        }
        let Some(cache) = &self.md_cache else {
            return (line, 1);
        };
        if cache.width == 0
            || line >= cache.lines.len()
            || cache.row_prefix.len() != cache.lines.len() + 1
        {
            return (line, 1);
        }
        let top = cache.row_prefix[line];
        let height = (cache.row_prefix[line + 1] - top).max(1);
        (top, height)
    }

    /// Activate the focused item: a link opens (URLs externally, local paths within konoma),
    /// a task checkbox toggles.
    pub fn md_activate_focused(&mut self) -> Result<()> {
        let Some(f) = self.tab.focused_item else {
            return Ok(());
        };
        match self.md_items.get(f) {
            Some(MdItem {
                kind: MdItemKind::Link { target },
                ..
            }) => {
                let target = target.clone();
                self.open_link_target(&target)
            }
            Some(MdItem {
                kind: MdItemKind::Task { .. },
                ..
            }) => {
                self.md_toggle_focused_task();
                Ok(())
            }
            // コードブロックは「開く/トグル」対象が無いので Enter では何もしない。
            // コピーは他のコピー操作と揃えて `y`(コピーリーダー)で行う(main.rs で先取り)。
            Some(MdItem {
                kind: MdItemKind::CodeBlock,
                ..
            }) => Ok(()),
            // インライン mermaid 図: 全画面(ズーム/パン付き)で開く。
            Some(MdItem {
                kind: MdItemKind::MermaidFence { ordinal },
                ..
            }) => {
                let ord = *ordinal;
                self.open_mermaid_fence(ord);
                Ok(())
            }
            // <details> の summary: 折りたたみをトグル。
            Some(MdItem {
                kind: MdItemKind::Details { ordinal },
                ..
            }) => {
                let ord = *ordinal;
                self.toggle_details(ord);
                Ok(())
            }
            None => Ok(()),
        }
    }

    /// Open the Nth ```mermaid fence of the current Markdown preview full screen (Enter on a
    /// Tab-focused inline diagram). The Markdown view's scroll/focus are stashed so `q` returns
    /// exactly where the reader was. Count-guarded re-extraction: if the file changed and the
    /// fence is gone, flash instead of rendering a stale diagram (principle #3).
    fn open_mermaid_fence(&mut self, ordinal: usize) {
        let Some(md) = self.tab.preview_path.clone() else {
            return;
        };
        if self.mermaid_fence_code(&md, ordinal).is_none() {
            self.flash = Some(tr(self.lang, crate::i18n::Msg::DiagramOpenFailed).into());
            return;
        }
        self.tab.fence_return = Some((self.tab.preview_scroll, self.tab.focused_item));
        self.clear_image();
        let kind = PreviewKind::MermaidFence(ordinal);
        self.start_media_load(&kind, &md);
        self.tab.preview_kind = Some(kind);
    }

    /// Raw source of the focused code block, matched by ordinal against the on-screen headers. The
    /// block text is re-scanned from the file; `None` when the focused item is not a code block,
    /// there is no path, the read fails, or the counts disagree (file changed / pathological doc) —
    /// the caller then flashes instead of copying garbage (safe fallback, #3). Also used by tests
    /// to assert the copied value without a clipboard round-trip.
    fn focused_code_source(&self) -> Option<String> {
        let f = self.tab.focused_item?;
        if !matches!(
            self.md_items.get(f),
            Some(MdItem {
                kind: MdItemKind::CodeBlock,
                ..
            })
        ) {
            return None;
        }
        // 文書内で何番目のコードブロックか(リンク/タスクを除いた序数)。
        let ordinal = self.md_items[..=f]
            .iter()
            .filter(|it| matches!(it.kind, MdItemKind::CodeBlock))
            .count()
            .saturating_sub(1);
        let total = self
            .md_items
            .iter()
            .filter(|it| matches!(it.kind, MdItemKind::CodeBlock))
            .count();
        let src = std::fs::read_to_string(self.tab.preview_path.as_ref()?).ok()?;
        // `<details>` の開閉は描画時の実効状態(md_cache が保持)。渡さないと閉じたブロック内の
        // フェンスまで数えて個数ガードが外れる。
        let details_states: Vec<bool> = self
            .md_cache
            .as_ref()
            .map(|c| c.details_states.clone())
            .unwrap_or_default();
        let blocks = crate::preview::markdown::code_block_source_locs(&src, &details_states);
        // 画面上のヘッダ数とソースのブロック数が一致するときだけ信頼して写す。
        (blocks.len() == total)
            .then(|| blocks.get(ordinal).cloned())
            .flatten()
    }

    /// Copy the focused code block's raw source to the clipboard, or flash a safe notice when it
    /// cannot be resolved (`focused_code_source` = None). Triggered by the copy leader (`y`) in a
    /// Markdown preview when a code block is focused (pre-empted in `handle_key`).
    pub fn md_copy_focused_code(&mut self) {
        let Some(text) = self.focused_code_source() else {
            self.flash = Some(tr(self.lang, crate::i18n::Msg::CodeBlockCopyUnavailable).into());
            return;
        };
        match set_clipboard(&text) {
            Ok(()) => self.flash = Some(tr(self.lang, crate::i18n::Msg::CopiedCodeBlock).into()),
            Err(e) => {
                self.flash = Some(format!(
                    "{}{e}",
                    tr(self.lang, crate::i18n::Msg::CopyFailed)
                ))
            }
        }
    }

    /// Test-only: the exact text `Enter` would copy for the focused code block (no clipboard).
    #[cfg(test)]
    pub fn focused_code_text(&self) -> Option<String> {
        self.focused_code_source()
    }

    /// Whether the currently focused Markdown item is a code block (drives the footer hint:
    /// `Enter` copies the block).
    pub fn md_focused_code(&self) -> bool {
        !self.is_raw_source()
            && self
                .tab
                .focused_item
                .and_then(|f| self.md_items.get(f))
                .is_some_and(|it| matches!(it.kind, MdItemKind::CodeBlock))
    }

    /// Whether the currently focused Markdown item is a task checkbox (drives the Space fixed key:
    /// only then does Space toggle instead of falling through to the keymap).
    pub fn md_focused_task(&self) -> bool {
        !self.is_raw_source()
            && self
                .tab
                .focused_item
                .and_then(|f| self.md_items.get(f))
                .is_some_and(|it| matches!(it.kind, MdItemKind::Task { .. }))
    }

    /// The ordinal of the focused `<details>` summary, if one is focused (drives the Space fixed key
    /// and the footer hint). None when not focused on a details summary.
    pub fn md_focused_details(&self) -> Option<usize> {
        if self.is_raw_source() {
            return None;
        }
        match self.tab.focused_item.and_then(|f| self.md_items.get(f)) {
            Some(MdItem {
                kind: MdItemKind::Details { ordinal },
                ..
            }) => Some(*ordinal),
            _ => None,
        }
    }

    /// Whether the rendered Markdown preview has any task checkboxes (drives the footer hint).
    pub fn md_has_tasks(&self) -> bool {
        self.md_items
            .iter()
            .any(|it| matches!(it.kind, MdItemKind::Task { .. }))
    }

    /// Open a link target. `scheme://`/`mailto:`, etc. are delegated externally; local paths open in konoma
    /// (file=preview / directory=make it the new root and Tree).
    pub(super) fn open_link_target(&mut self, target: &str) -> Result<()> {
        let t = target.trim();
        if t.contains("://") || t.starts_with("mailto:") || t.starts_with("tel:") {
            return self.open_external(t);
        }
        if let Some(anchor) = t.strip_prefix('#') {
            if !self.md_scroll_to_anchor(anchor) {
                self.flash = Some(format!(
                    "{}{}",
                    tr(self.lang, crate::i18n::Msg::AnchorNotFound),
                    t
                ));
            }
            return Ok(());
        }
        let path_part = t.split('#').next().unwrap_or(t);
        let resolved = self.resolve_link_local(t);
        if resolved.is_dir() {
            self.back_to_tree();
            // root を変えるので旧 root の選択/ビジュアル/絞り込み/検索を破棄する(持ち越さない)。
            self.clear_for_root_change();
            self.tab.root = resolved;
            self.tab.open_dir = self.tab.root.clone();
            self.tab.entries.clear();
            self.tab.selected = 0;
            self.rebuild_tree()?;
        } else if resolved.is_file() {
            self.enter_preview(&resolved);
        } else {
            self.flash = Some(format!(
                "{}{}",
                tr(self.lang, crate::i18n::Msg::NotFound),
                path_part
            ));
        }
        Ok(())
    }

    /// Resolve a local (non-URL, non-anchor) Markdown link target to an absolute path (existence not
    /// guaranteed), relative to the current preview file's directory. A `#anchor` suffix is dropped.
    fn resolve_link_local(&self, target: &str) -> PathBuf {
        let t = target.trim();
        let path_part = t.split('#').next().unwrap_or(t);
        let base = self
            .tab
            .preview_path
            .as_ref()
            .and_then(|p| p.parent())
            .map(Path::to_path_buf)
            .unwrap_or_else(|| self.tab.root.clone());
        let p = Path::new(path_part);
        let resolved = if p.is_absolute() {
            p.to_path_buf()
        } else {
            base.join(p)
        };
        std::fs::canonicalize(&resolved).unwrap_or(resolved)
    }

    /// `Ctrl-t`: open the focused Markdown link in a **new tab** (the current doc's tab stays intact).
    /// URLs still go to the browser (a tab makes no sense for them); a local file/dir opens in a fresh
    /// foreground tab via the shared paste-jump navigation (reveal + root-switch-if-outside + preview).
    /// No-op when the focused item is not a link (task/code) or nothing is focused.
    pub fn md_open_focused_link_new_tab(&mut self) -> Result<()> {
        let Some(f) = self.tab.focused_item else {
            return Ok(());
        };
        let target = match self.md_items.get(f) {
            Some(MdItem {
                kind: MdItemKind::Link { target },
                ..
            }) => target.clone(),
            _ => return Ok(()),
        };
        let t = target.trim();
        // URL/mailto/tel はタブにできないので、Enter と同じく外部(ブラウザ等)で開く。
        if t.contains("://") || t.starts_with("mailto:") || t.starts_with("tel:") {
            return self.open_external(t);
        }
        if t.starts_with('#') {
            // A same-document anchor makes no sense in a new tab — scroll in place instead.
            return self.open_link_target(t);
        }
        // タブを作る前に、現在の md ファイルの位置基準でローカルパスを解決しておく。
        let resolved = self.resolve_link_local(t);
        if !resolved.exists() {
            self.flash = Some(format!(
                "{}{}",
                tr(self.lang, crate::i18n::Msg::NotFound),
                t.split('#').next().unwrap_or(t)
            ));
            return Ok(());
        }
        // 新規タブ(前面)を作り、その中でジャンプする。元のドキュメントのタブは save_active で保持済み。
        self.tab_new()?;
        self.paste_jump_to(&resolved, None);
        Ok(())
    }

    /// Open a URL/file with an external command (macOS `open`). The result is reported via flash.
    fn open_external(&mut self, url: &str) -> Result<()> {
        match std::process::Command::new("open").arg(url).spawn() {
            Ok(_) => self.flash = Some(format!("{}{url}", tr(self.lang, crate::i18n::Msg::Opened))),
            Err(e) => {
                self.flash = Some(format!(
                    "{}{e}",
                    tr(self.lang, crate::i18n::Msg::OpenFailed)
                ))
            }
        }
        Ok(())
    }
}