mod files;
mod gist;
mod keys;
mod view;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::text::Span;
use tokio::task::JoinHandle;
use tui_tree_widget::TreeState;
use std::time::{Duration, Instant};
use crate::config::Config;
use crate::event::AsyncSender;
use crate::hydrate::AmbiguousMatch;
use crate::scanner::{self, ScannedFile};
use crate::store::Store;
use crate::sync;
use crate::ui::input::LineEditor;
use anyhow::Result;
pub const NO_TOKEN_HINT: &str =
"No GitHub token. Run `gh auth login` (or set GITHUB_TOKEN), then restart penknife.";
#[derive(Debug)]
pub enum Mode {
Normal,
Help,
FilePicker {
selected: usize,
},
Diff {
local: String,
remote: String,
},
Confirm {
message: String,
action: ConfirmAction,
},
GdocUrl,
GdocFilename,
Message(String),
RootSwitcher {
selected: usize,
},
AddRoot,
SetupRoot,
ResolveAmbiguous {
item: usize,
selected: usize,
},
SearchQuery,
SearchResults {
selected: usize,
},
ReplaceQuery,
ReplaceTarget,
ReplaceReview {
selected: usize,
},
Rename {
old_rel: String,
},
LinkGist {
rel_path: String,
},
SortMenu {
selected: usize,
},
BulkMenu {
selected: usize,
},
DeleteMenu {
selected: usize,
},
GitMenu {
selected: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteChoice {
Remote,
Local,
Both,
}
impl DeleteChoice {
pub fn label(self) -> &'static str {
match self {
Self::Remote => "Remote gist (keep local file)",
Self::Local => "Local file (to trash; keep gist)",
Self::Both => "Both (delete gist, trash file)",
}
}
}
pub const GIT_MENU_LABELS: &[&str] = &["git status", "git log", "git pull --rebase", "git push"];
#[derive(Debug, Clone)]
pub enum BulkAction {
PushAllDirty { rels: Vec<String> },
PullAllRemoteNewer { rels: Vec<String> },
FormatAllJson { rels: Vec<String> },
PruneOrphans { rels: Vec<String> },
}
impl BulkAction {
pub fn count(&self) -> usize {
match self {
Self::PushAllDirty { rels }
| Self::PullAllRemoteNewer { rels }
| Self::FormatAllJson { rels }
| Self::PruneOrphans { rels } => rels.len(),
}
}
pub fn label(&self) -> &'static str {
match self {
Self::PushAllDirty { .. } => "Push all dirty",
Self::PullAllRemoteNewer { .. } => "Pull all remote-newer",
Self::FormatAllJson { .. } => "Format all JSON",
Self::PruneOrphans { .. } => "Prune store orphans",
}
}
}
#[derive(Debug, Clone)]
pub enum ConfirmAction {
SyncDown,
ForcePush {
rel_path: String,
},
DeleteRemote {
rel_path: String,
root: PathBuf,
remote_id: String,
},
TrashLocal {
rel_path: String,
root: PathBuf,
},
DeleteBoth {
rel_path: String,
root: PathBuf,
remote_id: String,
},
Bulk(BulkAction),
RunShell {
cmd: String,
},
}
pub struct App {
pub config: Config,
pub store: Store,
pub files: Vec<ScannedFile>,
pub tree_items: Vec<tui_tree_widget::TreeItem<'static, String>>,
pub tree_identifiers: Vec<String>,
pub tree_file_ids: HashSet<String>,
pub tree_state: TreeState<String>,
pub mode: Mode,
pub preview_content: String,
pub status_message: String,
pub status_color: Color,
pub status_spans: Vec<Span<'static>>,
pub picker_editor: LineEditor,
pub picker: crate::picker::Picker,
pub picker_matches: Vec<crate::picker::PickerMatch>,
pub input_editor: LineEditor,
pub gdoc_content: Option<String>,
pub pending_import_entry: Option<crate::store::FileEntry>,
pub should_quit: bool,
pub async_tx: AsyncSender,
pub token: Option<String>,
pub active_root: usize,
pub pending_ambiguous: Vec<AmbiguousMatch>,
pub pending_copy: Option<String>,
pub preview_scroll: u16,
pub diff_scroll: u16,
pub tree_pane_rect: Rect,
pub right_pane_rect: Rect,
pub focused_pane: PaneFocus,
pub mouse_capture: bool,
pub tasks: Vec<JoinHandle<()>>,
pub pending_editor: Option<PathBuf>,
pub pending_alias: Option<String>,
pub search_query: String,
pub search_matches: Vec<crate::replace::ReplaceMatch>,
pub replace_query: String,
pub replace_target: String,
pub replace_matches: Vec<crate::replace::ReplaceMatch>,
pub replace_checked: Vec<bool>,
pub git_repo_root: Option<PathBuf>,
pub git_statuses: std::collections::HashMap<String, crate::git::GitStatus>,
pub status_cache: std::collections::HashMap<String, sync::SyncStatus>,
pub last_remote_poll: Option<Instant>,
pub remote_check_inflight: bool,
pub remote_poll_failures: u32,
pub last_local_sweep: Option<Instant>,
pub startup_hydrate_done: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneFocus {
Tree,
Right,
}
fn files_differ(current: &[ScannedFile], scanned: &[ScannedFile]) -> bool {
if current.len() != scanned.len() {
return true;
}
let key = |f: &ScannedFile| (f.rel_path.clone(), f.modified);
let mut a: Vec<_> = current.iter().map(key).collect();
let mut b: Vec<_> = scanned.iter().map(key).collect();
a.sort();
b.sort();
a != b
}
const RESERVED_KEYS: &[char] = &[
'q', '?', '/', 'j', 'k', 'l', 'h', 'u', 'd', 'c', 'C', 'V', 'e', 'o', 'X', 'n', 'N', 'D', 'M',
'R', 'I', 's', 'm', 'O', 'B', 'g', 'L', 'f', 'p',
];
impl App {
pub fn new(async_tx: AsyncSender) -> Result<Self> {
let mut config = Config::load()?;
let store = Store::load()?;
let mut dropped: Vec<String> = Vec::new();
config.aliases.retain(|k, _cmd| {
let chars: Vec<char> = k.chars().collect();
if chars.len() != 1 {
dropped.push(format!("'{k}' (not a single char)"));
return false;
}
if RESERVED_KEYS.contains(&chars[0]) {
dropped.push(format!("'{k}' (built-in)"));
return false;
}
true
});
let alias_warning = if dropped.is_empty() {
None
} else {
Some(format!(
"Dropped alias{}: {}",
if dropped.len() == 1 { "" } else { "es" },
dropped.join(", ")
))
};
let token = penknife_gist::auth::resolve_token().ok();
let start_mode = if config.roots.is_empty() {
Mode::SetupRoot
} else {
Mode::Normal
};
let files = if config.roots.is_empty() {
Vec::new()
} else {
let ignore = scanner::build_globset(&config.roots[0].ignore);
scanner::scan_directory(&config.roots[0].path, &ignore).unwrap_or_default()
};
let mut app = App {
config,
store,
files,
tree_items: Vec::new(),
tree_identifiers: Vec::new(),
tree_file_ids: HashSet::new(),
tree_state: TreeState::default(),
mode: start_mode,
preview_content: String::new(),
status_message: String::new(),
status_color: Color::White,
status_spans: Vec::new(),
picker_editor: LineEditor::new(),
picker: crate::picker::Picker::new(),
picker_matches: Vec::new(),
input_editor: LineEditor::new(),
gdoc_content: None,
pending_import_entry: None,
should_quit: false,
async_tx,
token,
active_root: 0,
pending_ambiguous: Vec::new(),
pending_copy: None,
preview_scroll: 0,
diff_scroll: 0,
tree_pane_rect: Rect::default(),
right_pane_rect: Rect::default(),
focused_pane: PaneFocus::Tree,
mouse_capture: false,
tasks: Vec::new(),
pending_editor: None,
pending_alias: None,
search_query: String::new(),
search_matches: Vec::new(),
replace_query: String::new(),
replace_target: String::new(),
replace_matches: Vec::new(),
replace_checked: Vec::new(),
git_repo_root: None,
git_statuses: std::collections::HashMap::new(),
status_cache: std::collections::HashMap::new(),
last_remote_poll: None,
remote_check_inflight: false,
remote_poll_failures: 0,
last_local_sweep: Some(Instant::now()),
startup_hydrate_done: false,
};
app.refresh_status_cache();
app.refresh_git_status();
app.rebuild_tree();
app.update_status();
if app.token.is_none() {
app.status_message = "No GitHub token: browsing and p (copy as rich text) work now. \
Run `gh auth login` or set GITHUB_TOKEN to enable sync."
.into();
}
if let Some(w) = alias_warning {
app.status_message = w;
}
Ok(app)
}
pub fn tick(&mut self) {
if !matches!(self.mode, Mode::Normal) {
return;
}
let local_secs = self.config.poll.local_secs;
if local_secs > 0
&& self
.last_local_sweep
.is_none_or(|t| t.elapsed() >= Duration::from_secs(local_secs))
{
self.last_local_sweep = Some(Instant::now());
if let Some(entry) = self.current_root_entry()
&& entry.path.exists()
{
let ignore = scanner::build_globset(&entry.ignore);
if let Ok(scanned) = scanner::scan_directory(&entry.path, &ignore)
&& files_differ(&self.files, &scanned)
&& let Err(e) = self.refresh_files()
{
self.status_message = format!("Refresh failed: {e}");
}
}
}
if !self.startup_hydrate_done {
self.startup_hydrate_done = true;
if self.token.is_some() && !self.files.is_empty() {
self.start_hydration();
}
}
let remote_secs = self.config.poll.remote_secs;
if remote_secs > 0 && self.token.is_some() && !self.remote_check_inflight {
let effective = remote_secs * (1u64 << self.remote_poll_failures.min(6));
if self
.last_remote_poll
.is_none_or(|t| t.elapsed() >= Duration::from_secs(effective))
{
self.last_remote_poll = Some(Instant::now());
self.start_remote_check();
}
}
}
fn current_root(&self) -> Option<&PathBuf> {
self.config.roots.get(self.active_root).map(|r| &r.path)
}
fn current_root_entry(&self) -> Option<&crate::config::Root> {
self.config.roots.get(self.active_root)
}
pub fn active_root_path(&self) -> Option<PathBuf> {
self.current_root().cloned()
}
fn spawn_tracked<F>(&mut self, fut: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
self.tasks.retain(|h| !h.is_finished());
self.tasks.push(tokio::spawn(fut));
}
pub fn abort_tasks(&mut self) {
for h in self.tasks.drain(..) {
h.abort();
}
}
pub fn selected_file(&self) -> Option<String> {
let selected = self.tree_state.selected();
let id = selected.last()?.clone();
if self.tree_file_ids.contains(&id) {
Some(id)
} else {
None
}
}
pub fn abs_path(&self, rel_path: &str) -> PathBuf {
if let Some(root) = self.current_root() {
root.join(rel_path)
} else {
PathBuf::from(rel_path)
}
}
}
fn shell_quote(path: &Path) -> String {
let s = path.to_string_lossy();
let escaped = s.replace('\'', "'\\''");
format!("'{escaped}'")
}
fn expand_tilde(raw: &str) -> PathBuf {
if let Some(rest) = raw.strip_prefix('~')
&& let Some(home) = dirs::home_dir()
{
home.join(rest.strip_prefix('/').unwrap_or(rest))
} else {
PathBuf::from(raw)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_tilde_passes_absolute_through() {
assert_eq!(expand_tilde("/foo/bar"), PathBuf::from("/foo/bar"));
}
#[test]
fn expand_tilde_passes_relative_through() {
assert_eq!(expand_tilde("foo/bar"), PathBuf::from("foo/bar"));
}
#[test]
fn expand_tilde_rewrites_home_prefix() {
let Some(home) = dirs::home_dir() else {
return; };
assert_eq!(expand_tilde("~/Documents"), home.join("Documents"));
assert_eq!(expand_tilde("~"), home);
}
fn scanned(rel: &str, secs: u64) -> ScannedFile {
ScannedFile {
rel_path: rel.to_string(),
abs_path: PathBuf::from(format!("/x/{rel}")),
modified: std::time::UNIX_EPOCH + Duration::from_secs(secs),
}
}
#[test]
fn files_differ_ignores_order() {
let a = vec![scanned("a.md", 1), scanned("b.md", 2)];
let b = vec![scanned("b.md", 2), scanned("a.md", 1)];
assert!(!files_differ(&a, &b));
}
#[test]
fn files_differ_detects_mtime_change() {
let a = vec![scanned("a.md", 1)];
let b = vec![scanned("a.md", 9)];
assert!(files_differ(&a, &b));
}
#[test]
fn files_differ_detects_membership_change() {
let a = vec![scanned("a.md", 1)];
let b = vec![scanned("a.md", 1), scanned("new.md", 2)];
assert!(files_differ(&a, &b));
assert!(files_differ(&b, &a));
let c = vec![scanned("other.md", 1)];
assert!(files_differ(&a, &c));
}
}