Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
name: opencode

on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
pull_request:
types: [opened, synchronize, reopened, ready_for_review]

jobs:
opencode-review:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: write
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,15 @@ on:
env:
CARGO_TERM_COLOR: always

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
name: ${{ matrix.target }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
permissions:
contents: read
strategy:
Expand Down Expand Up @@ -46,6 +51,7 @@ jobs:
env:
TARGET: ${{ matrix.target }}
run: cargo build --release --locked --target "$TARGET"
shell: bash

- name: Package
env:
Expand All @@ -72,6 +78,7 @@ jobs:
name: GitHub Release
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ on:
env:
CARGO_TERM_COLOR: always

concurrency:
group: rust-${{ github.ref }}
cancel-in-progress: true

jobs:
rust:
runs-on: ${{ matrix.os }}
timeout-minutes: 30
permissions:
contents: read

Expand All @@ -33,12 +38,16 @@ jobs:

- name: Check formatting
run: cargo fmt --check
shell: bash

- name: Clippy
run: cargo clippy --locked --all-targets -- -D warnings
shell: bash

- name: Test
run: cargo test --locked
shell: bash

- name: Build release
run: cargo build --release --locked
shell: bash
8 changes: 6 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
name = "librecommander"
version = "0.1.1"
edition = "2024"
rust-version = "1.95"
rust-version = "1.95" # edition 2024 + inline let-chains used throughout
description = "Modern dual-panel MC for Norton/MC muscle memory in one offline Rust binary — no async runtime, forbid(unsafe), zip-safe archives"
license = "MIT"
readme = "README.md"
repository = "https://github.com/leszek3737/LibreCommander"
homepage = "https://github.com/leszek3737/LibreCommander"
keywords = ["tui", "file-manager", "ratatui", "cli", "terminal"]
categories = ["command-line-utilities", "filesystem"]
exclude = [".github/", ".serena/"]
exclude = [".github/", ".serena/", "docs/"]

# crates.io package is `librecommander` (`lc` is taken). Binary + lib stay `lc`.
[lib]
Expand Down Expand Up @@ -52,12 +52,16 @@ tempfile = "3"
[profile.release]
lto = true
codegen-units = 1
panic = "abort"

# Dev: light optimization keeps TUI interaction responsive while
# preserving full debug info and fast incremental rebuilds.
[profile.dev]
opt-level = 1

# FSEvents-only on macOS: kqueue is excluded deliberately. FSEvents is the
# recommended backend for recursive directory watching; kqueue requires a
# file descriptor per watched directory and is slow/large on big trees.
[target.'cfg(target_os = "macos")'.dependencies]
notify = { version = "8", default-features = false, features = ["macos_fsevent"] }

Expand Down
22 changes: 22 additions & 0 deletions src/app/keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,28 @@ mod tests {
}
}

/// `build_help_message` emits a section header only on mode change, so it
/// silently assumes KEYBINDINGS is pre-grouped by mode. This test fails if
/// a binding is added out of order, preventing duplicated/interspersed
/// mode headers in the help output.
#[test]
fn keybindings_are_grouped_by_mode() {
let mut seen_modes: Vec<&'static str> = Vec::new();
for b in KEYBINDINGS {
if !seen_modes.contains(&b.mode) {
seen_modes.push(b.mode);
} else {
assert!(
seen_modes.last() == Some(&b.mode),
"Binding for mode {:?} ({}) appears after a different mode — \
KEYBINDINGS must be grouped by mode",
b.mode,
b.key
);
}
}
}

#[test]
fn all_app_modes_have_keymap_or_documented_fallback() {
let msg = build_help_message();
Expand Down
29 changes: 1 addition & 28 deletions src/app/types/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ pub struct InputState {
/// Transient presentation state: status line, menus, pickers, the directory
/// hotlist, user-menu data, the deferred action awaiting confirmation, and the
/// viewer spinner animation.
///
/// `Default` is implemented by hand because [`MenuSource`] has no `Default`.
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, Default)]
pub struct UiState {
pub status_message: Option<String>,
pub menu_selected: usize,
Expand Down Expand Up @@ -65,31 +63,6 @@ pub struct UiState {
pub viewer_spinner_last_tick: Option<Instant>,
}

impl Default for UiState {
fn default() -> Self {
Self {
status_message: None,
menu_selected: 0,
menu_item_selected: 0,
picker_selected: 0,
user_menu_entries: Vec::new(),
user_menu_source: MenuSource::Global,
cached_hotlist_strings: Vec::new(),
cached_user_menu_strings: Vec::new(),
cached_history_strings: Vec::new(),
pending_menu_command: None,
pending_hotlist_delete: None,
pending_archive_list: None,
pending_tree_build: None,
menu_restore_panel: None,
directory_hotlist: Vec::new(),
pending_action: None,
viewer_spinner_frame: 0,
viewer_spinner_last_tick: None,
}
}
}

/// Directory-tree browser view state (the `DirectoryTree` mode).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TreeState {
Expand Down
6 changes: 3 additions & 3 deletions src/app/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ mod tests;

// --- Re-exports -----------------------------------------------------------
// Grouped by shape: data types (structs/enums) first, then free utility
// functions. WS-E debt: this is a flat ~30-symbol facade; a later pass could
// split it into per-concern submodule facades if the surface keeps growing.
// functions.

// State containers & aggregates (AppState plus its extracted sub-states).
pub use app_state::{AppState, InputState, InteractionState, TreeState, UiState};
Expand Down Expand Up @@ -51,6 +50,7 @@ pub use text_input::TextInput;
// callers use `FileEntry::display_permissions_raw` directly.
pub use file_entry::{compute_category, format_size, format_time};

// `sanitize_for_display` is only needed by test helpers.
// `sanitize_for_display` is used by test helpers and the production display
// path (e.g. non-UTF-8 filename rendering).
#[cfg(test)]
pub(crate) use file_entry::sanitize_for_display;
5 changes: 3 additions & 2 deletions src/app/types/modes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,9 @@ mod tests {
// Guards the hand-maintained `CompareMode::ALL` against drift. The inner
// `match` is exhaustive, so adding a new `CompareMode` variant breaks
// compilation here until the author updates both the match and `ALL`; the
// length assertion then catches a variant that was added to the enum but
// forgotten in the array (or vice versa).
// length assertion catches a variant added to the enum but forgotten in
// the array (or vice versa). (`std::mem::variant_count` would be ideal but
// is still unstable as of Rust 1.95.)
#[test]
fn compare_mode_all_is_exhaustive_and_in_order() {
for (i, variant) in CompareMode::ALL.iter().enumerate() {
Expand Down
22 changes: 15 additions & 7 deletions src/app/user_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -424,9 +424,10 @@ fn is_regular_file(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|m| !m.is_symlink() && m.is_file())
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MenuSource {
Local,
#[default]
Global,
}

Expand Down Expand Up @@ -458,12 +459,19 @@ pub struct LoadedMenu {
}

pub fn load_menu_with_warnings(panel_dir: &Path, filename: &str) -> Result<LoadedMenu, String> {
let (path, source) = locate_menu_file(panel_dir).ok_or_else(|| {
format!(
"No user menu file found (searched: {}/.mc.menu, ~/.config/lc/menu)",
panel_dir.display()
)
})?;
let global = paths::user_menu_path();
let (path, source) =
locate_menu_file_with_global(panel_dir, global.as_deref()).ok_or_else(|| {
let global_display = global
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(no global menu path configured)".to_string());
format!(
"No user menu file found (searched: {}/.mc.menu, {})",
panel_dir.display(),
global_display
)
})?;
let mut content = String::new();
// Read one byte past the limit so an oversize file is a hard error rather
// than a silently truncated (and possibly mis-parsed) prefix.
Expand Down
11 changes: 2 additions & 9 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,8 @@
//!
//! Built with Ratatui + Crossterm. Single binary, no runtime dependencies.

// Public API surface.
//
// The `lc` binary (`main.rs`, `render*`, `input/*`, integration tests under
// `src/tests/`) consumes this library as an external crate and always reaches
// items through their module path (e.g. `lc::app::types::AppState`,
// `lc::ops::compare::compare_entries`). The crate-root re-exports that used to
// live here were never referenced via `lc::<Symbol>` by any consumer, so they
// were a redundant, inconsistent second surface. The intended public API is the
// set of top-level modules below; navigate into them for concrete items.
// The `lc` binary and integration tests consume this library as an external
// crate, reaching items through their module path (e.g. `lc::app::types`).
pub mod app;
pub mod fs;
pub mod menu;
Expand Down
Loading