inkhaven 1.3.9

Inkhaven — TUI literary work editor for Typst books
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
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
//! 1.2.14+ Phase C.1 — inline comments on paragraph
//! prose.  Sidecar JSON storage adjacent to the
//! `.typ` file so comments travel with the
//! paragraph in git and diff cleanly across
//! commits.
//!
//! Character-offset spans (not byte) so UTF-8
//! boundary edits don't corrupt anchoring.  Stable
//! per-comment UUIDs so a referenced comment in
//! an AI digest or beta-reader export survives
//! author edits to the surrounding text.
//!
//! Sidecar shape (1.2.14 schema v1):
//!
//! ```json
//! {
//!   "schema_version": 1,
//!   "comments": [
//!     {
//!       "id": "01939c2a-...",
//!       "char_start": 412,
//!       "char_end": 487,
//!       "author": "vladimir",
//!       "created_at": "2026-06-01T10:23:00Z",
//!       "resolved": false,
//!       "resolved_at": null,
//!       "text": "Does this sentence land the redemption beat?",
//!       "replies": []
//!     }
//!   ]
//! }
//! ```
//!
//! Phase C.1 ships add + render + footer chip.
//! Phase C.2 adds the comments panel (`Ctrl+V
//! Shift+C`), resolve / unresolve, AI digest, and
//! the CLI surface.  See
//! `Documentation/PROPOSALS/1.2.14_PLAN.md` §4.

use std::path::{Path, PathBuf};

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// One inline comment anchored to a character span
/// in a paragraph's prose.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
    pub id: Uuid,
    /// Character offset (NOT byte) where the
    /// comment span begins.  Inclusive.
    pub char_start: usize,
    /// Character offset where the comment span
    /// ends.  Exclusive.
    pub char_end: usize,
    pub author: String,
    pub created_at: DateTime<Utc>,
    #[serde(default)]
    pub resolved: bool,
    #[serde(default)]
    pub resolved_at: Option<DateTime<Utc>>,
    pub text: String,
    /// Reserved for Phase C.2 threading.  Empty
    /// vec round-trips cleanly.
    #[serde(default)]
    pub replies: Vec<CommentReply>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentReply {
    pub id: Uuid,
    pub author: String,
    pub created_at: DateTime<Utc>,
    pub text: String,
}

/// Top-level container in the sidecar JSON file.
/// `schema_version` lets future format migrations
/// detect old files without guessing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentsFile {
    pub schema_version: u32,
    #[serde(default)]
    pub comments: Vec<Comment>,
}

impl CommentsFile {
    pub fn new() -> Self {
        Self {
            schema_version: 1,
            comments: Vec::new(),
        }
    }
}

impl Default for CommentsFile {
    fn default() -> Self {
        Self::new()
    }
}

/// 1.2.14+ Phase C.1 — derive the sidecar path
/// from the paragraph's `.typ` path.  Just swap
/// the extension: `foo.typ` → `foo.comments.json`.
/// Files without a `.typ` extension are still
/// accepted (e.g. `.hjson` or `.bund` paragraphs)
/// — we append `.comments.json` to the stem.
pub fn sidecar_path(typ_path: &Path) -> PathBuf {
    let mut out = typ_path.to_path_buf();
    let stem = out
        .file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| "paragraph".to_string());
    out.set_file_name(format!("{stem}.comments.json"));
    out
}

/// 1.2.14+ Phase C.1 — load comments for a
/// paragraph.  Returns an empty `CommentsFile`
/// when the sidecar doesn't exist (the common
/// case — most paragraphs carry no comments).
/// Errors only on malformed JSON, which surfaces
/// up so the editor can show a diagnostic
/// instead of silently dropping the user's
/// comment history.
pub fn load_from_sidecar(typ_abs_path: &Path) -> Result<CommentsFile, String> {
    let path = sidecar_path(typ_abs_path);
    if !path.exists() {
        return Ok(CommentsFile::new());
    }
    let raw = std::fs::read_to_string(&path)
        .map_err(|e| format!("read {}: {e}", path.display()))?;
    if raw.trim().is_empty() {
        return Ok(CommentsFile::new());
    }
    serde_json::from_str(&raw)
        .map_err(|e| format!("parse {}: {e}", path.display()))
}

/// 1.2.14+ Phase C.1 — persist comments for a
/// paragraph.  Writes pretty JSON for git-friendly
/// diffs.  Removes the sidecar entirely when the
/// comments list is empty (avoid littering the
/// project with empty JSON files).
pub fn save_to_sidecar(
    typ_abs_path: &Path,
    file: &CommentsFile,
) -> Result<(), String> {
    let path = sidecar_path(typ_abs_path);
    if file.comments.is_empty() {
        if path.exists() {
            std::fs::remove_file(&path).map_err(|e| {
                format!("remove empty sidecar {}: {e}", path.display())
            })?;
        }
        return Ok(());
    }
    let raw = serde_json::to_string_pretty(file)
        .map_err(|e| format!("serialise: {e}"))?;
    // 1.2.15+ Phase S.4 — atomic write so a panic
    // or power loss mid-save can't truncate the user's
    // comment history.  Comments are user data — the
    // "no data loss" goal makes atomic the right
    // default here.
    crate::io_atomic::write(&path, raw.as_bytes())
        .map_err(|e| format!("write {}: {e}", path.display()))
}

/// 1.2.14+ Phase C.1 — resolve the comment
/// author.  Priority:
///   1. `editor.comment_author` HJSON field if set
///      (project-level override).
///   2. `$USER` env var.
///   3. `$LOGNAME` env var.
///   4. Hostname via `hostname` env.
///   5. `"anonymous"` fallback.
pub fn resolve_author(configured: Option<&str>) -> String {
    if let Some(s) = configured {
        let t = s.trim();
        if !t.is_empty() {
            return t.to_string();
        }
    }
    for var in ["USER", "LOGNAME", "HOSTNAME"] {
        if let Ok(v) = std::env::var(var) {
            let t = v.trim();
            if !t.is_empty() {
                return t.to_string();
            }
        }
    }
    "anonymous".to_string()
}

/// 1.2.14+ Phase C.1 — convert a global character
/// offset within the paragraph body to (row, col)
/// in the textarea's `lines()` view.  Returns
/// `None` when the offset is past end-of-buffer.
/// Counting matches `tui-textarea`'s convention:
/// each line is one row, `\n` separators count as
/// one character between rows.
pub fn char_offset_to_row_col(
    lines: &[String],
    offset: usize,
) -> Option<(usize, usize)> {
    let mut consumed = 0usize;
    for (row, line) in lines.iter().enumerate() {
        let line_chars = line.chars().count();
        if offset <= consumed + line_chars {
            return Some((row, offset - consumed));
        }
        // +1 for the newline between this line and
        // the next.
        consumed += line_chars + 1;
    }
    None
}

/// 1.2.14+ Phase C.1 — convert a (row, col) cursor
/// position to a global character offset.  Inverse
/// of `char_offset_to_row_col`; used by the `Ctrl+V
/// c` handler to translate the textarea cursor /
/// selection range into sidecar-storage
/// coordinates.
pub fn row_col_to_char_offset(
    lines: &[String],
    row: usize,
    col: usize,
) -> usize {
    let mut offset = 0usize;
    for (r, line) in lines.iter().enumerate() {
        if r == row {
            // Cap at the line's character count so a
            // col past EOL still produces a sensible
            // offset.
            return offset + col.min(line.chars().count());
        }
        offset += line.chars().count() + 1;
    }
    offset
}

/// 1.2.14+ Phase C.1 — derive a comment span from
/// the textarea's cursor / selection state.  When
/// a selection exists, the comment anchors to that
/// range.  When no selection, anchors to the word
/// at the cursor (Unicode word boundaries).
/// Returns `None` only when the cursor is on a
/// blank line outside any word AND no selection
/// is active — caller should surface a friendly
/// error in that case.
pub fn derive_anchor_span(
    lines: &[String],
    cursor: (usize, usize),
    selection: Option<((usize, usize), (usize, usize))>,
) -> Option<(usize, usize)> {
    if let Some(((r1, c1), (r2, c2))) = selection {
        let start = row_col_to_char_offset(lines, r1, c1);
        let end = row_col_to_char_offset(lines, r2, c2);
        if start != end {
            // tui-textarea may swap start/end
            // depending on selection direction —
            // normalise.
            let (s, e) = if start < end { (start, end) } else { (end, start) };
            return Some((s, e));
        }
    }
    // No selection — anchor to the word at the
    // cursor.  Find word boundaries on the cursor's
    // line by walking outward.
    let (cur_row, cur_col) = cursor;
    let line = lines.get(cur_row)?;
    let chars: Vec<char> = line.chars().collect();
    if chars.is_empty() {
        return None;
    }
    let cur_col = cur_col.min(chars.len());
    // Walk left while the char before is
    // word-character-y.
    let is_word_char = |c: char| c.is_alphanumeric() || c == '_' || c == '\'';
    let mut start = cur_col;
    while start > 0 && is_word_char(chars[start - 1]) {
        start -= 1;
    }
    let mut end = cur_col;
    while end < chars.len() && is_word_char(chars[end]) {
        end += 1;
    }
    if start == end {
        return None;
    }
    let abs_start = row_col_to_char_offset(lines, cur_row, start);
    let abs_end = row_col_to_char_offset(lines, cur_row, end);
    Some((abs_start, abs_end))
}

/// 1.2.14+ Phase C.1 — for each editor row, return
/// the (col_start, col_end, comment_index) tuples
/// of comment spans that intersect that row.
/// `col_end == usize::MAX` signals the span
/// continues past the visible row width — the
/// renderer clamps.
pub fn per_row_hits(
    lines: &[String],
    comments: &[Comment],
) -> Vec<Vec<RowHit>> {
    let mut out: Vec<Vec<RowHit>> = vec![Vec::new(); lines.len()];
    for (idx, c) in comments.iter().enumerate() {
        let Some((r_start, c_start)) =
            char_offset_to_row_col(lines, c.char_start)
        else {
            continue;
        };
        // Span end maps to (row, col) AT the end
        // boundary; if the offset is exactly the
        // newline between rows, the span ends at
        // EOL of `r_start`.
        let (r_end, c_end) = match char_offset_to_row_col(lines, c.char_end) {
            Some(p) => p,
            None => {
                // Offset past end-of-buffer — clamp
                // to the last line's EOL.
                let last_row = lines.len().saturating_sub(1);
                let last_col = lines
                    .last()
                    .map(|l| l.chars().count())
                    .unwrap_or(0);
                (last_row, last_col)
            }
        };
        for row in r_start..=r_end.min(lines.len().saturating_sub(1)) {
            let line_chars = lines[row].chars().count();
            let start_col = if row == r_start { c_start } else { 0 };
            let end_col = if row == r_end { c_end } else { line_chars };
            if start_col >= end_col {
                continue;
            }
            out[row].push(RowHit {
                col_start: start_col,
                col_end: end_col,
                comment_idx: idx,
                resolved: c.resolved,
            });
        }
    }
    out
}

#[derive(Debug, Clone, Copy)]
pub struct RowHit {
    pub col_start: usize,
    pub col_end: usize,
    /// Index into the paragraph's
    /// `CommentsFile.comments` vector — used by
    /// Phase C.2 panel navigation to map a cell
    /// back to its source comment.
    #[allow(dead_code)]
    pub comment_idx: usize,
    pub resolved: bool,
}

/// 1.2.14+ Phase C.1 — find the comment whose
/// span contains the cursor position (if any).
/// Used by the editor footer to surface the
/// comment text when the cursor lands inside a
/// commented span.  Returns the first matching
/// index — overlapping comments tie-break by
/// insertion order.
pub fn comment_at_cursor(
    lines: &[String],
    comments: &[Comment],
    cursor: (usize, usize),
) -> Option<usize> {
    let cursor_offset = row_col_to_char_offset(lines, cursor.0, cursor.1);
    comments.iter().position(|c| {
        cursor_offset >= c.char_start && cursor_offset < c.char_end
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn mk_lines(s: &[&str]) -> Vec<String> {
        s.iter().map(|x| (*x).to_string()).collect()
    }

    #[test]
    fn sidecar_path_swaps_extension() {
        let p = Path::new("/proj/books/manuscript/chapter-1/03-rain.typ");
        assert_eq!(
            sidecar_path(p).to_string_lossy(),
            "/proj/books/manuscript/chapter-1/03-rain.comments.json"
        );
    }

    #[test]
    fn sidecar_path_handles_non_typ_extension() {
        let p = Path::new("/proj/x/note.hjson");
        assert_eq!(
            sidecar_path(p).to_string_lossy(),
            "/proj/x/note.comments.json"
        );
    }

    #[test]
    fn char_offset_to_row_col_basic() {
        let lines = mk_lines(&["hello", "world"]);
        // "hello\n" = chars 0..5 then newline = char 5;
        // "world" starts at char 6.
        assert_eq!(char_offset_to_row_col(&lines, 0), Some((0, 0)));
        assert_eq!(char_offset_to_row_col(&lines, 5), Some((0, 5)));
        assert_eq!(char_offset_to_row_col(&lines, 6), Some((1, 0)));
        assert_eq!(char_offset_to_row_col(&lines, 11), Some((1, 5)));
        assert_eq!(char_offset_to_row_col(&lines, 12), None);
    }

    #[test]
    fn row_col_roundtrip() {
        let lines = mk_lines(&["hello", "world", "tira"]);
        for (r, c) in
            [(0, 0), (0, 3), (1, 0), (1, 5), (2, 4)]
        {
            let off = row_col_to_char_offset(&lines, r, c);
            assert_eq!(char_offset_to_row_col(&lines, off), Some((r, c)));
        }
    }

    #[test]
    fn char_offset_handles_unicode() {
        // Cyrillic is 2 bytes per char in UTF-8 but
        // one CHARACTER each.  Our offsets are
        // character-based so the math is the same as
        // ASCII.
        let lines = mk_lines(&["Москва", "Питер"]);
        assert_eq!(char_offset_to_row_col(&lines, 6), Some((0, 6)));
        assert_eq!(char_offset_to_row_col(&lines, 7), Some((1, 0)));
        assert_eq!(char_offset_to_row_col(&lines, 11), Some((1, 4)));
    }

    #[test]
    fn derive_anchor_uses_selection_when_present() {
        let lines = mk_lines(&["hello world", "foo bar"]);
        // Select "world" via ((0, 6), (0, 11)).
        let span = derive_anchor_span(
            &lines,
            (0, 11),
            Some(((0, 6), (0, 11))),
        );
        assert_eq!(span, Some((6, 11)));
    }

    #[test]
    fn derive_anchor_normalises_reverse_selection() {
        let lines = mk_lines(&["hello world"]);
        // Selection captured backward (end before start).
        let span = derive_anchor_span(
            &lines,
            (0, 0),
            Some(((0, 11), (0, 6))),
        );
        assert_eq!(span, Some((6, 11)));
    }

    #[test]
    fn derive_anchor_falls_back_to_word_at_cursor() {
        let lines = mk_lines(&["hello world here"]);
        // Cursor in the middle of "world".
        let span = derive_anchor_span(&lines, (0, 8), None);
        assert_eq!(span, Some((6, 11)));
    }

    #[test]
    fn derive_anchor_returns_none_on_blank() {
        let lines = mk_lines(&["", "   ", ""]);
        assert!(derive_anchor_span(&lines, (0, 0), None).is_none());
        assert!(derive_anchor_span(&lines, (2, 0), None).is_none());
    }

    fn mk_comment(start: usize, end: usize) -> Comment {
        Comment {
            id: Uuid::nil(),
            char_start: start,
            char_end: end,
            author: "test".into(),
            created_at: Utc::now(),
            resolved: false,
            resolved_at: None,
            text: "test comment".into(),
            replies: Vec::new(),
        }
    }

    #[test]
    fn per_row_hits_single_line_span() {
        let lines = mk_lines(&["hello world"]);
        let cs = vec![mk_comment(6, 11)];
        let hits = per_row_hits(&lines, &cs);
        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].len(), 1);
        assert_eq!(hits[0][0].col_start, 6);
        assert_eq!(hits[0][0].col_end, 11);
        assert!(!hits[0][0].resolved);
    }

    #[test]
    fn per_row_hits_propagates_resolved_flag() {
        let lines = mk_lines(&["hello world"]);
        let mut c = mk_comment(6, 11);
        c.resolved = true;
        let hits = per_row_hits(&lines, &[c]);
        assert!(hits[0][0].resolved);
    }

    #[test]
    fn per_row_hits_multiline_span() {
        let lines = mk_lines(&["hello world", "foo bar baz"]);
        // "hello world" is chars 0..11, newline is
        // char 11, "foo bar baz" is chars 12..23.
        // Span (6, 19) covers "world\nfoo bar".
        // End char 19 - 12 = col 7 on row 1
        // (exclusive end → highlight stops at the
        // space after "bar").
        let cs = vec![mk_comment(6, 19)];
        let hits = per_row_hits(&lines, &cs);
        assert_eq!(hits[0].len(), 1);
        assert_eq!(hits[0][0].col_start, 6);
        assert_eq!(hits[0][0].col_end, 11);
        assert_eq!(hits[1].len(), 1);
        assert_eq!(hits[1][0].col_start, 0);
        assert_eq!(hits[1][0].col_end, 7);
    }

    #[test]
    fn comment_at_cursor_finds_containing_comment() {
        let lines = mk_lines(&["hello world here"]);
        let cs = vec![mk_comment(6, 11)];
        assert_eq!(comment_at_cursor(&lines, &cs, (0, 8)), Some(0));
        assert_eq!(comment_at_cursor(&lines, &cs, (0, 6)), Some(0));
        // 11 is exclusive — cursor at col 11 is past
        // the span.
        assert_eq!(comment_at_cursor(&lines, &cs, (0, 11)), None);
        assert_eq!(comment_at_cursor(&lines, &cs, (0, 3)), None);
    }

    #[test]
    fn resolve_author_uses_configured_when_set() {
        assert_eq!(resolve_author(Some("vladimir")), "vladimir");
        // Whitespace-only configured value falls
        // through to env vars.
        assert_eq!(resolve_author(Some("  ")).len() > 0, true);
    }

    #[test]
    fn comments_file_roundtrip() {
        let mut f = CommentsFile::new();
        f.comments.push(mk_comment(0, 5));
        let raw = serde_json::to_string(&f).unwrap();
        let back: CommentsFile = serde_json::from_str(&raw).unwrap();
        assert_eq!(back.schema_version, 1);
        assert_eq!(back.comments.len(), 1);
    }
}