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
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use crossterm::event::{self, Event, KeyEvent, MouseEvent};
use tokio::sync::mpsc;
use crate::git::GitStatus;
use crate::hydrate::{AmbiguousMatch, HydrationProgress};
use crate::scanner::ScannedFile;
use crate::store::Store;
use crate::sync::SyncStatus;
/// Payload of a successful gist-import fetch: the file's markdown content,
/// the gist's filename (prefills the save-as prompt), and the ready-made
/// store entry (import means local == remote, i.e. born synced).
#[derive(Debug)]
pub struct GistImportData {
pub content: String,
pub filename: String,
pub entry: crate::store::FileEntry,
}
#[derive(Debug)]
pub struct HydrationDoneData {
pub matched: usize,
pub ambiguous: Vec<AmbiguousMatch>,
pub store: Box<Store>,
/// The root this walk covered - the per-root hydration cursor is keyed by it.
pub root: PathBuf,
/// The timestamp to record as `root`'s new hydration cursor - captured
/// when the walk began, so gists created mid-walk are caught on the next
/// pass rather than skipped.
pub new_cursor: chrono::DateTime<chrono::Utc>,
}
/// Messages from async tasks back to the main UI loop.
#[derive(Debug)]
pub enum AsyncEvent {
/// Gist push completed for a file
PushDone {
root: PathBuf,
rel_path: String,
result: std::result::Result<crate::store::FileEntry, String>,
},
/// Gist pull completed
PullDone {
root: PathBuf,
rel_path: String,
/// SHA-256 of the local file when the pull was initiated. If the
/// on-disk content no longer matches by the time this event lands
/// (user edited mid-pull), the write is refused.
expected_local_sha256: String,
result: std::result::Result<(String, crate::store::FileEntry), String>,
},
/// Push refused because the remote changed since the last sync.
/// Carries the freshly observed remote state so the store can record
/// the divergence and the UI can offer a force-push.
PushBlocked {
root: PathBuf,
rel_path: String,
remote_sha256: String,
remote_updated_at: chrono::DateTime<chrono::Utc>,
},
/// Progress for a bulk remote check (`f`).
/// Bulk remote check finished. `started` timestamps the check so stale
/// results don't clobber entries that synced while it ran.
RemoteCheckDone {
root: PathBuf,
started: chrono::DateTime<chrono::Utc>,
result: std::result::Result<crate::remote::RemoteCheckOutcome, String>,
},
/// Hydration progress update
HydrationUpdate(HydrationProgress),
/// Hydration finished. On success, carries the (partial) updated store
/// so the main thread can merge in the discovered mappings without
/// clobbering concurrent writes, plus any ambiguous matches that need
/// user resolution.
HydrationDone(std::result::Result<HydrationDoneData, String>),
/// Remote status check result (from the diff view's fetch). Carries the
/// observed remote state so it can be written back to the store - a
/// diff is also a remote check.
StatusCheck {
root: PathBuf,
rel_path: String,
/// When the fetch began; write-back is skipped if the entry synced
/// after this (the sync result is the newer truth).
started: chrono::DateTime<chrono::Utc>,
result: std::result::Result<crate::sync::FullStatus, String>,
},
/// Google Doc fetch result
GdocFetched(std::result::Result<String, String>),
/// Gist import fetch result: content plus the mapping to record once the
/// file is saved locally.
GistImportFetched(std::result::Result<GistImportData, String>),
/// Remote gist deletion completed
DeleteDone {
root: PathBuf,
rel_path: String,
result: std::result::Result<(), String>,
},
/// Manual link (`L`) completed: a gist was fetched and reconciled against
/// the selected local file. On success carries the store entry to record.
LinkDone {
root: PathBuf,
rel_path: String,
result: std::result::Result<crate::store::FileEntry, String>,
},
/// Remote gist filename update (companion to a local rename) completed.
/// `rel_path` here is the new rel_path (for the status message);
/// the local store + filesystem are already updated by the time this
/// event fires, so we don't need the root.
RenameRemoteDone {
rel_path: String,
result: std::result::Result<(), String>,
},
/// An off-thread refresh (directory scan + per-file sync-status cache +
/// `git status`) finished. Everything here was computed on a blocking-pool
/// thread so the render loop never stalls on the walk, the file reads, or
/// the git shell-out; the main loop just adopts the result.
RefreshDone {
/// Refresh sequence number, compared against the app's current
/// generation on apply so a superseded (older) refresh is dropped.
generation: u64,
/// The root this refresh scanned; dropped if the active root changed
/// while it ran.
root: PathBuf,
files: Vec<ScannedFile>,
status_cache: HashMap<String, SyncStatus>,
git_repo_root: Option<PathBuf>,
git_statuses: HashMap<String, GitStatus>,
/// If set, jump the tree selection to this rel_path after applying
/// (used after a rename so the moved file stays selected).
select: Option<String>,
/// If set, the result is discarded unless the file set actually
/// differs from what's shown - the periodic local sweep uses this so
/// idle browsing doesn't rebuild the tree (or reset preview scroll)
/// every interval.
only_if_changed: bool,
/// Status-bar message to show once the refresh lands, applied *after*
/// the dashboard/status refresh so an operation's own "Done" line wins
/// over the recomputed default.
done_message: Option<String>,
},
/// Live progress from the initial (startup) scan: how many files the walk
/// has found so far. Lets the tree pane show a `dua`-style counter instead
/// of a blank frame while a large (e.g. cloud-synced) root is scanned.
ScanProgress {
/// The refresh this progress belongs to; ignored if superseded.
generation: u64,
count: usize,
},
}
/// User-input events from the terminal that the app cares about.
#[derive(Debug)]
pub enum UiEvent {
Key(KeyEvent),
Mouse(MouseEvent),
}
/// Poll for a crossterm UI event (key or mouse) with the given timeout.
pub fn poll_event(timeout: Duration) -> Option<UiEvent> {
if !event::poll(timeout).ok()? {
return None;
}
match event::read().ok()? {
Event::Key(k) => Some(UiEvent::Key(k)),
Event::Mouse(m) => Some(UiEvent::Mouse(m)),
_ => None,
}
}
pub type AsyncSender = mpsc::UnboundedSender<AsyncEvent>;
pub type AsyncReceiver = mpsc::UnboundedReceiver<AsyncEvent>;
pub fn async_channel() -> (AsyncSender, AsyncReceiver) {
mpsc::unbounded_channel()
}