Skip to content

Commit 6805cf0

Browse files
committed
Add bookmark system
1 parent 4f34b5d commit 6805cf0

20 files changed

Lines changed: 241 additions & 9 deletions

README.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ FileCTRL is a light, opinionated, responsive, theme-able, and simple Text User I
1313
- File operations: chmod, create directory, and [copy/cut paste across instances/windows](#copy--paste)
1414
- Go to path with path completion suggestions
1515
- Recursive search
16+
- [Bookmarks](#bookmarks) - save and quickly return to frequently-used folders
1617
- Responsive layout: adapts columns and content to small and large terminal windows
1718

1819
## Installation
@@ -82,6 +83,22 @@ Mark files to apply bulk operations (chmod, copy, cut, delete) to multiple items
8283
- <kbd>Esc</kbd> clears all marks and exits range mode
8384
- Marks and clipboard are mutually exclusive -marking clears the clipboard
8485

86+
### Bookmarks
87+
88+
Bookmarks are symlinks to folders, stored in a `bookmarks/` directory beside
89+
the resolved config file (e.g. `~/.config/filectrl/bookmarks/`, or
90+
`<dir>/bookmarks/` when `--config <dir>/config.toml` is used).
91+
92+
- <kbd>B</kbd> adds a bookmark for the **current directory**. A prompt asks for
93+
the bookmark name, defaulting to the current directory's name. Names must be
94+
unique, cannot be empty, and cannot contain a path separator.
95+
- <kbd>'</kbd> or <kbd>&#96;</kbd> shows all bookmarks in the table. This is an
96+
ephemeral view (the breadcrumbs show `[Bookmarks]`); press <kbd>Esc</kbd> to
97+
return to the previous directory.
98+
- Opening a bookmark navigates to the linked folder.
99+
- Bookmarks can be renamed (<kbd>r</kbd>) or deleted (<kbd>d</kbd>) like regular
100+
files; this renames/deletes the symlink, never the target folder.
101+
85102
### Default keybindings
86103

87104
All keybindings can be [customized](#customizing-keybindings).
@@ -110,12 +127,14 @@ Create directory | <kbd>c</kbd>
110127
Delete | <kbd>d</kbd>/<kbd>Delete</kbd>
111128
Filter | <kbd>f</kbd>/<kbd>\</kbd>
112129
Search | <kbd>/</kbd>
130+
Add bookmark | <kbd>B</kbd> (Uppercase)
131+
Show bookmarks | <kbd>'</kbd>/<kbd>&#96;</kbd>
113132
Refresh | <kbd>Ctrl</kbd>+<kbd>r</kbd>/<kbd>F5</kbd>
114133
Sort by name, modified, size | <kbd>n</kbd>, <kbd>m</kbd>, <kbd>s</kbd>
115134
Toggle show hidden files | <kbd>.</kbd>
116135
Cancel file or search operations | <kbd>K</kbd> (Uppercase)
117136
Clear alerts, progress | <kbd>Ctrl</kbd>+<kbd>a</kbd>, <kbd>Ctrl</kbd>+<kbd>p</kbd>
118-
Clear clipboard/filter/marks/search | <kbd>Esc</kbd>
137+
Clear clipboard/filter/marks/search, exit bookmarks view | <kbd>Esc</kbd>
119138
Toggle help | <kbd>?</kbd>
120139
Quit | <kbd>q</kbd>
121140

@@ -244,7 +263,7 @@ Section | Description
244263
`prompt` | Input prompt (`cursor`, `input`, `label`, `selected`)
245264
`scrollbar` | Scrollbar (`ends`, `thumb`, `track`, plus `show_ends` boolean)
246265
`status` | Status bar (`detail`, `label`)
247-
`table` | File table (`body`, `header`, `header_sorted`, `selected`, `marked`, `delete`)
266+
`table` | File table (`body`, `header`, `header_sorted`, `selected`, `marked`, `delete`, `bookmark`)
248267

249268
#### LS_COLORS integration
250269

src/app/config.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ struct RawConfig {
6565
}
6666

6767
pub struct Config {
68+
pub config_dir: PathBuf,
6869
pub file_system: FileSystemConfig,
6970
pub is_truecolor: bool,
7071
pub keybindings: KeyBindings,
@@ -164,7 +165,13 @@ impl Config {
164165
// CLI --include paths are merged last, so they take precedence over everything
165166
value = merge_include_paths(value, include_paths)?;
166167

167-
Self::parse_value(value)
168+
Self::parse_value(value, config_dir)
169+
}
170+
171+
/// The directory containing the resolved config file. Bookmarks live in a
172+
/// `bookmarks/` subdirectory beside it.
173+
pub fn bookmarks_dir(&self) -> PathBuf {
174+
self.config_dir.join("bookmarks")
168175
}
169176

170177
/// Resolves the config's `include_files` array into absolute-or-relative
@@ -203,7 +210,11 @@ impl Config {
203210
.collect()
204211
}
205212

206-
fn parse_value(value: Value) -> Result<Self> {
213+
fn parse_value(value: Value, config_dir: Option<PathBuf>) -> Result<Self> {
214+
let config_dir = config_dir
215+
.or_else(|| Self::default_config_dir().ok())
216+
.unwrap_or_default();
217+
207218
let raw: RawConfig = value
208219
.try_into()
209220
.map_err(|error| anyhow!("Cannot deserialize config: {error}"))?;
@@ -217,6 +228,7 @@ impl Config {
217228
let keybindings = KeyBindings::new(&raw.keybindings)?;
218229

219230
let mut config = Config {
231+
config_dir,
220232
file_system: raw.file_system,
221233
is_truecolor: false,
222234
keybindings,

src/app/config/default_config.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ sort_directories_first = true
4242
# and modifier prefixes ("Ctrl+c", "Ctrl+Shift+a").
4343
[keybindings]
4444
# Normal mode
45+
add_bookmark = "B"
4546
back = ["h", "b", "Backspace"]
4647
cancel_task = "K"
4748
chmod = "P"
@@ -66,6 +67,7 @@ range_mark = "V"
6667
refresh = ["Ctrl+r", "F5"]
6768
rename = ["r", "F2"]
6869
search = "/"
70+
show_bookmarks = ["'", "`"]
6971
select_first = ["g", "^"]
7072
select_last = ["G", "$"]
7173
select_middle = "z"

src/app/config/default_theme.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,11 @@ fg = "#DDDCCC"
228228
bg = "#423F2E"
229229
fg = "#DDDCCC"
230230

231+
[theme.table.bookmark]
232+
bg = "#423F2E"
233+
fg = "#E0A85C" # Amber
234+
modifiers = ["bold"]
235+
231236
[theme.table.delete]
232237
bg = "#d2bb84" # Tan
233238
fg = "#24201A"
@@ -479,6 +484,11 @@ fg = "253" # #dadada
479484
bg = "237" # #3a3a3a
480485
fg = "253" # #dadada
481486

487+
[theme256.table.bookmark]
488+
bg = "237" # #3a3a3a
489+
fg = "179" # #d7af5f - amber
490+
modifiers = ["bold"]
491+
482492
[theme256.table.delete]
483493
bg = "180" # #d7af87 - tan (approx #d2bb84)
484494
fg = "235" # #262626

src/app/config/keybindings.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@ pub enum Action {
4646
Paste,
4747

4848
// File operations
49+
AddBookmark,
4950
Chmod,
5051
CreateDirectory,
5152
Delete,
5253
Filter,
5354
Goto,
5455
Rename,
5556
Search,
57+
ShowBookmarks,
5658

5759
// Sort
5860
SortByModified,
@@ -151,6 +153,7 @@ keybindings! {
151153
normal {
152154
back => Back,
153155
go_to_previous_directory => GoToPreviousDirectory,
156+
add_bookmark => AddBookmark,
154157
cancel_task => CancelTask,
155158
chmod => Chmod,
156159
clear_alerts => ClearAlerts,
@@ -173,6 +176,7 @@ keybindings! {
173176
refresh => Refresh,
174177
rename => Rename,
175178
search => Search,
179+
show_bookmarks => ShowBookmarks,
176180
select_first => SelectFirst,
177181
select_last => SelectLast,
178182
select_middle => SelectMiddle,

src/app/config/theme.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,8 @@ impl ScrollbarConfig {
276276
pub struct Table {
277277
body: StyleConfig,
278278
#[serde(default)]
279+
bookmark: StyleConfig,
280+
#[serde(default)]
279281
delete: StyleConfig,
280282
header: StyleConfig,
281283
header_sorted: StyleConfig,
@@ -286,6 +288,7 @@ pub struct Table {
286288

287289
impl Table {
288290
style_getter!(body);
291+
style_getter!(bookmark);
289292
style_getter!(delete);
290293
style_getter!(header);
291294
style_getter!(header_sorted);

src/command.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ pub enum PromptAction {
2424
paths: Vec<PathInfo>,
2525
mode: String,
2626
},
27+
AddBookmark {
28+
directory: PathInfo,
29+
name: String,
30+
},
2731
#[default]
2832
CreateDirectory,
2933
Delete(usize),
@@ -77,6 +81,14 @@ pub enum Command {
7781
srcs: Vec<PathInfo>,
7882
dest: PathInfo,
7983
},
84+
AddBookmark {
85+
directory: PathInfo,
86+
name: String,
87+
},
88+
ShowBookmarks, // Intent: resolved by FileSystem into ShowedBookmarks
89+
ShowedBookmarks {
90+
bookmarks: Vec<PathInfo>,
91+
},
8092
CreateDirectory(String),
8193
Delete(Vec<PathInfo>),
8294
Move {

src/file_system.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,39 @@ impl FileSystem {
259259
self.refresh()
260260
}
261261

262+
fn add_bookmark(&mut self, target: &PathInfo, name: &str) -> CommandResult {
263+
match operations::add_bookmark(target, name) {
264+
Err(error) => Command::AlertError(error.to_string()).into(),
265+
Ok(_) => Command::AlertInfo(format!("Bookmark {name:?} added")).into(),
266+
}
267+
}
268+
269+
/// Read every entry in the bookmarks directory and send them as a single
270+
/// batch. Synchronous: one small directory of symlinks, no streaming.
271+
fn show_bookmarks(&self) {
272+
let dir = Config::global().bookmarks_dir();
273+
if let Err(error) = fs::create_dir_all(&dir) {
274+
let _ = self.command_tx.send(Command::AlertError(format!(
275+
"Cannot create bookmarks directory {dir:?}: {error}"
276+
)));
277+
return;
278+
}
279+
match fs::read_dir(&dir) {
280+
Ok(entries) => {
281+
let bookmarks: Vec<PathInfo> = entries
282+
.flatten()
283+
.filter_map(|entry| PathInfo::try_from(&entry.path()).ok())
284+
.collect();
285+
let _ = self.command_tx.send(Command::ShowedBookmarks { bookmarks });
286+
}
287+
Err(error) => {
288+
let _ = self.command_tx.send(Command::AlertError(format!(
289+
"Cannot read bookmarks directory {dir:?}: {error}"
290+
)));
291+
}
292+
}
293+
}
294+
262295
fn create_directory(&mut self, name: &str) -> CommandResult {
263296
match operations::create_directory(self.current_directory(), name) {
264297
Err(error) => anyhow!("Failed to create directory {name:?}: {error}").into(),

src/file_system/handler.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ impl CommandHandler for FileSystem {
1111
self.cancel_search();
1212
CommandResult::NotHandled
1313
}
14+
Command::AddBookmark { directory, name } => self.add_bookmark(directory, name),
15+
Command::ShowBookmarks => {
16+
self.show_bookmarks();
17+
CommandResult::Handled
18+
}
1419
Command::Chmod { paths, mode } => self.chmod(paths, mode),
1520
Command::CreateDirectory(name) => self.create_directory(name),
1621
Command::Copy { srcs, dest } => {

src/file_system/operations.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use anyhow::{Result, anyhow};
1212
use log::{info, warn};
1313

1414
use super::path_info::PathInfo;
15-
use crate::command::Command;
15+
use crate::{app::config::Config, command::Command};
1616

1717
pub(super) fn cd(directory: &PathInfo) -> Result<(Vec<PathInfo>, usize)> {
1818
info!("Changing directory to {directory:?}");
@@ -75,6 +75,29 @@ pub(super) fn chmod(path: &PathInfo, mode: u32) -> Result<()> {
7575
Ok(())
7676
}
7777

78+
pub(super) fn add_bookmark(target: &PathInfo, name: &str) -> Result<()> {
79+
let name = name.trim();
80+
if name.is_empty() {
81+
return Err(anyhow!("Bookmark name cannot be empty"));
82+
}
83+
if name.contains(std::path::MAIN_SEPARATOR) {
84+
return Err(anyhow!(
85+
"Bookmark name cannot contain {:?}",
86+
std::path::MAIN_SEPARATOR
87+
));
88+
}
89+
let dir = Config::global().bookmarks_dir();
90+
fs::create_dir_all(&dir)?;
91+
let link = dir.join(name);
92+
// Reject duplicates, including a pre-existing broken symlink.
93+
if link.symlink_metadata().is_ok() {
94+
return Err(anyhow!("A bookmark named {name:?} already exists"));
95+
}
96+
info!("Creating bookmark {link:?} -> {:?}", target.path);
97+
std::os::unix::fs::symlink(&target.path, &link)?;
98+
Ok(())
99+
}
100+
78101
pub(super) fn create_directory(parent: &PathInfo, name: &str) -> Result<()> {
79102
let path = parent.as_path().join(name);
80103
info!("Creating directory {path:?}");

0 commit comments

Comments
 (0)