Show each file's slug as a file-list hover tooltip
Hovering a file in the left pane now shows its `# Slug:` line as a tooltip — a one-line synopsis per chapter. Slugs are cached per file (name -> slug), filled when the workspace opens (snapshot_slugs) and refreshed on save; the selected file is parsed live from the buffer so unsaved slug edits show immediately. The cache is kept consistent on rename and delete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N9kRuP7JvXoUGdNNeg5ZSs
This commit is contained in:
@@ -4,7 +4,8 @@ A small desktop application for Debian that treats a directory of markdown files
|
||||
as an ordered manuscript.
|
||||
|
||||
* **Left pane** — every `*.md` file in the workspace directory, in a manual order
|
||||
you set by **dragging the `⠿` handle** up and down.
|
||||
you set by **dragging the `⠿` handle** up and down. **Hover a file** to see its
|
||||
`# Slug:` line as a tooltip — a handy one-line synopsis of each chapter.
|
||||
* **Right pane** — a markdown text editor for the selected file (optional live
|
||||
preview via the *Preview* checkbox).
|
||||
* **Order + contents are saved and version-controlled with git.** The order lives
|
||||
|
||||
+56
-9
@@ -74,6 +74,10 @@ pub struct App {
|
||||
/// Each file's word count captured when the workspace was opened, used as the
|
||||
/// per-file baseline for the "this session" delta.
|
||||
session_start_counts: HashMap<String, usize>,
|
||||
/// Cached `# Slug:` value per file (name -> slug) for the file-list hover
|
||||
/// tooltip. Filled when the workspace opens and refreshed on save; the
|
||||
/// selected file is read live from the buffer instead.
|
||||
slugs: HashMap<String, String>,
|
||||
/// Grammar/spelling issues from the last LanguageTool check.
|
||||
lt_matches: Vec<crate::langtool::Match>,
|
||||
/// The exact buffer text the current `lt_matches` were computed against;
|
||||
@@ -159,6 +163,7 @@ impl App {
|
||||
repo_root: None,
|
||||
pending_repo: None,
|
||||
session_start_counts: HashMap::new(),
|
||||
slugs: HashMap::new(),
|
||||
lt_matches: Vec::new(),
|
||||
lt_checked_text: String::new(),
|
||||
lt_status: String::new(),
|
||||
@@ -216,6 +221,7 @@ impl App {
|
||||
self.pending_delete = false;
|
||||
self.clear_lt();
|
||||
self.session_start_counts = self.snapshot_counts();
|
||||
self.slugs = self.snapshot_slugs();
|
||||
if !self.files.is_empty() {
|
||||
self.select(0);
|
||||
}
|
||||
@@ -289,6 +295,19 @@ impl App {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read every file and return its `# Slug:` header value (only for files that
|
||||
/// have one), for the file-list hover tooltip.
|
||||
fn snapshot_slugs(&self) -> HashMap<String, String> {
|
||||
self.files
|
||||
.iter()
|
||||
.filter_map(|name| {
|
||||
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||||
let header = crate::preprocess::parse(&text, &self.config.draft_marker);
|
||||
header.slug.map(|slug| (name.clone(), slug))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn persist_order(&self) {
|
||||
let _ = order::write_order(self.workspace(), &self.files);
|
||||
}
|
||||
@@ -321,12 +340,26 @@ impl App {
|
||||
fn save_current(&mut self) {
|
||||
if let Some(idx) = self.selected {
|
||||
if self.dirty {
|
||||
if let Some(name) = self.files.get(idx) {
|
||||
let path = self.path_for(name);
|
||||
if let Some(name) = self.files.get(idx).cloned() {
|
||||
let path = self.path_for(&name);
|
||||
match std::fs::write(&path, &self.buffer) {
|
||||
Ok(_) => {
|
||||
self.dirty = false;
|
||||
self.status = format!("Saved {name}");
|
||||
// Keep the hover-tooltip slug cache in step.
|
||||
let slug = crate::preprocess::parse(
|
||||
&self.buffer,
|
||||
&self.config.draft_marker,
|
||||
)
|
||||
.slug;
|
||||
match slug {
|
||||
Some(s) => {
|
||||
self.slugs.insert(name, s);
|
||||
}
|
||||
None => {
|
||||
self.slugs.remove(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.status = format!("Save failed: {e}"),
|
||||
}
|
||||
@@ -412,6 +445,7 @@ impl App {
|
||||
self.files.remove(idx);
|
||||
self.titles.remove(&name);
|
||||
self.session_start_counts.remove(&name);
|
||||
self.slugs.remove(&name);
|
||||
self.persist_titles();
|
||||
self.persist_order();
|
||||
self.selected = None;
|
||||
@@ -463,6 +497,9 @@ impl App {
|
||||
if let Some(words) = self.session_start_counts.remove(&old_name) {
|
||||
self.session_start_counts.insert(new_name.clone(), words);
|
||||
}
|
||||
if let Some(slug) = self.slugs.remove(&old_name) {
|
||||
self.slugs.insert(new_name.clone(), slug);
|
||||
}
|
||||
self.persist_order();
|
||||
self.status = format!("Renamed to {new_name}");
|
||||
}
|
||||
@@ -1288,6 +1325,15 @@ impl App {
|
||||
for idx in 0..self.files.len() {
|
||||
let name = self.files[idx].clone();
|
||||
let selected = self.selected == Some(idx);
|
||||
// The selected file's slug is read live from the
|
||||
// buffer (so unsaved edits show); others come from the
|
||||
// cache filled on open/save.
|
||||
let slug = if selected {
|
||||
crate::preprocess::parse(&self.buffer, &self.config.draft_marker)
|
||||
.slug
|
||||
} else {
|
||||
self.slugs.get(&name).cloned()
|
||||
};
|
||||
let row = ui
|
||||
.horizontal(|ui| {
|
||||
ui.dnd_drag_source(
|
||||
@@ -1299,13 +1345,14 @@ impl App {
|
||||
);
|
||||
},
|
||||
);
|
||||
if ui
|
||||
.add_sized(
|
||||
[ui.available_width(), 20.0],
|
||||
egui::SelectableLabel::new(selected, &name),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let mut label = ui.add_sized(
|
||||
[ui.available_width(), 20.0],
|
||||
egui::SelectableLabel::new(selected, &name),
|
||||
);
|
||||
if let Some(slug) = &slug {
|
||||
label = label.on_hover_text(slug);
|
||||
}
|
||||
if label.clicked() {
|
||||
clicked = Some(idx);
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user