diff --git a/book/src/configuration.md b/book/src/configuration.md
index eb2cf473cffd..1a28bdba3017 100644
--- a/book/src/configuration.md
+++ b/book/src/configuration.md
@@ -37,33 +37,33 @@ Its settings will be merged with the configuration directory `config.toml` and t
### `[editor]` Section
-| Key | Description | Default |
-|--|--|---------|
-| `scrolloff` | Number of lines of padding around the edge of the screen when scrolling | `5` |
-| `mouse` | Enable mouse mode | `true` |
-| `middle-click-paste` | Middle click paste support | `true` |
-| `scroll-lines` | Number of lines to scroll per scroll wheel step | `3` |
-| `shell` | Shell to use when running external commands | Unix: `["sh", "-c"]`
Windows: `["cmd", "/C"]` |
-| `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers | `absolute` |
-| `cursorline` | Highlight all lines with a cursor | `false` |
-| `cursorcolumn` | Highlight all columns with a cursor | `false` |
-| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` |
-| `auto-completion` | Enable automatic pop up of auto-completion | `true` |
-| `auto-format` | Enable automatic formatting on save | `true` |
-| `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` |
-| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant | `400` |
-| `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` |
-| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` |
-| `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` |
-| `auto-info` | Whether to display info boxes | `true` |
-| `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative | `false` |
-| `undercurl` | Set to `true` to override automatic detection of terminal undercurl support in the event of a false negative | `false` |
-| `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file | `[]` |
-| `bufferline` | Renders a line at the top of the editor displaying open buffers. Can be `always`, `never` or `multiple` (only shown if more than one buffer is in use) | `never` |
-| `color-modes` | Whether to color the mode indicator with different colors depending on the mode itself | `false` |
-| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` |
-| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` |
-| `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` |
+| Key | Description | Default |
+| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
+| `scrolloff` | Number of lines of padding around the edge of the screen when scrolling | `5` |
+| `mouse` | Enable mouse mode | `true` |
+| `middle-click-paste` | Middle click paste support | `true` |
+| `scroll-lines` | Number of lines to scroll per scroll wheel step | `3` |
+| `shell` | Shell to use when running external commands | Unix: `["sh", "-c"]`
Windows: `["cmd", "/C"]` |
+| `line-number` | Line number display: `absolute` simply shows each line's number, while `relative` shows the distance from the current line. When unfocused or in insert mode, `relative` will still show absolute line numbers | `absolute` |
+| `cursorline` | Highlight all lines with a cursor | `false` |
+| `cursorcolumn` | Highlight all columns with a cursor | `false` |
+| `gutters` | Gutters to display: Available are `diagnostics` and `diff` and `line-numbers` and `spacer`, note that `diagnostics` also includes other features like breakpoints, 1-width padding will be inserted if gutters is non-empty | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` |
+| `auto-completion` | Enable automatic pop up of auto-completion | `true` |
+| `auto-format` | Enable automatic formatting on save | `true` |
+| `auto-save` | Enable automatic saving on the focus moving away from Helix. Requires [focus event support](https://github.com/helix-editor/helix/wiki/Terminal-Support) from your terminal | `false` |
+| `idle-timeout` | Time in milliseconds since last keypress before idle timers trigger. Used for autocompletion, set to 0 for instant | `400` |
+| `preview-completion-insert` | Whether to apply completion item instantly when selected | `true` |
+| `completion-trigger-len` | The min-length of word under cursor to trigger autocompletion | `2` |
+| `completion-replace` | Set to `true` to make completions always replace the entire word and not just the part before the cursor | `false` |
+| `auto-info` | Whether to display info boxes | `true` |
+| `true-color` | Set to `true` to override automatic detection of terminal truecolor support in the event of a false negative | `false` |
+| `undercurl` | Set to `true` to override automatic detection of terminal undercurl support in the event of a false negative | `false` |
+| `rulers` | List of column positions at which to display the rulers. Can be overridden by language specific `rulers` in `languages.toml` file | `[]` |
+| `bufferline` | Renders a line at the top of the editor displaying open buffers. Can be `always`, `never` or `multiple` (only shown if more than one buffer is in use) | `never` |
+| `color-modes` | Whether to color the mode indicator with different colors depending on the mode itself | `false` |
+| `text-width` | Maximum line length. Used for the `:reflow` command and soft-wrapping if `soft-wrap.wrap-at-text-width` is set | `80` |
+| `workspace-lsp-roots` | Directories relative to the workspace root that are treated as LSP roots. Should only be set in `.helix/config.toml` | `[]` |
+| `default-line-ending` | The line ending to use for new documents. Can be `native`, `lf`, `crlf`, `ff`, `cr` or `nel`. `native` uses the platform's native line ending (`crlf` on Windows, otherwise `lf`). | `native` |
### `[editor.statusline]` Section
@@ -85,57 +85,57 @@ mode.normal = "NORMAL"
mode.insert = "INSERT"
mode.select = "SELECT"
```
+
The `[editor.statusline]` key takes the following sub-keys:
-| Key | Description | Default |
-| --- | --- | --- |
-| `left` | A list of elements aligned to the left of the statusline | `["mode", "spinner", "file-name", "read-only-indicator", "file-modification-indicator"]` |
-| `center` | A list of elements aligned to the middle of the statusline | `[]` |
-| `right` | A list of elements aligned to the right of the statusline | `["diagnostics", "selections", "register", "position", "file-encoding"]` |
-| `separator` | The character used to separate elements in the statusline | `"│"` |
-| `mode.normal` | The text shown in the `mode` element for normal mode | `"NOR"` |
-| `mode.insert` | The text shown in the `mode` element for insert mode | `"INS"` |
-| `mode.select` | The text shown in the `mode` element for select mode | `"SEL"` |
+| Key | Description | Default |
+| ------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
+| `left` | A list of elements aligned to the left of the statusline | `["mode", "spinner", "file-name", "read-only-indicator", "file-modification-indicator"]` |
+| `center` | A list of elements aligned to the middle of the statusline | `[]` |
+| `right` | A list of elements aligned to the right of the statusline | `["diagnostics", "selections", "register", "position", "file-encoding"]` |
+| `separator` | The character used to separate elements in the statusline | `"│"` |
+| `mode.normal` | The text shown in the `mode` element for normal mode | `"NOR"` |
+| `mode.insert` | The text shown in the `mode` element for insert mode | `"INS"` |
+| `mode.select` | The text shown in the `mode` element for select mode | `"SEL"` |
The following statusline elements can be configured:
-| Key | Description |
-| ------ | ----------- |
-| `mode` | The current editor mode (`mode.normal`/`mode.insert`/`mode.select`) |
-| `spinner` | A progress spinner indicating LSP activity |
-| `file-name` | The path/name of the opened file |
-| `file-base-name` | The basename of the opened file |
+| Key | Description |
+| ----------------------------- | --------------------------------------------------------------------------------------------------- |
+| `mode` | The current editor mode (`mode.normal`/`mode.insert`/`mode.select`) |
+| `spinner` | A progress spinner indicating LSP activity |
+| `file-name` | The path/name of the opened file |
+| `file-base-name` | The basename of the opened file |
| `file-modification-indicator` | The indicator to show whether the file is modified (a `[+]` appears when there are unsaved changes) |
-| `file-encoding` | The encoding of the opened file if it differs from UTF-8 |
-| `file-line-ending` | The file line endings (CRLF or LF) |
-| `read-only-indicator` | An indicator that shows `[readonly]` when a file cannot be written |
-| `total-line-numbers` | The total line numbers of the opened file |
-| `file-type` | The type of the opened file |
-| `diagnostics` | The number of warnings and/or errors |
-| `workspace-diagnostics` | The number of warnings and/or errors on workspace |
-| `selections` | The number of active selections |
-| `primary-selection-length` | The number of characters currently in primary selection |
-| `position` | The cursor position |
-| `position-percentage` | The cursor position as a percentage of the total number of lines |
-| `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) |
-| `spacer` | Inserts a space between elements (multiple/contiguous spacers may be specified) |
-| `version-control` | The current branch name or detached commit hash of the opened workspace |
-| `register` | The current selected register |
+| `file-encoding` | The encoding of the opened file if it differs from UTF-8 |
+| `file-line-ending` | The file line endings (CRLF or LF) |
+| `read-only-indicator` | An indicator that shows `[readonly]` when a file cannot be written |
+| `total-line-numbers` | The total line numbers of the opened file |
+| `file-type` | The type of the opened file |
+| `diagnostics` | The number of warnings and/or errors |
+| `workspace-diagnostics` | The number of warnings and/or errors on workspace |
+| `selections` | The number of active selections |
+| `primary-selection-length` | The number of characters currently in primary selection |
+| `position` | The cursor position |
+| `position-percentage` | The cursor position as a percentage of the total number of lines |
+| `separator` | The string defined in `editor.statusline.separator` (defaults to `"│"`) |
+| `spacer` | Inserts a space between elements (multiple/contiguous spacers may be specified) |
+| `version-control` | The current branch name or detached commit hash of the opened workspace |
+| `register` | The current selected register |
### `[editor.lsp]` Section
-| Key | Description | Default |
-| --- | ----------- | ------- |
-| `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings.| `true` |
-| `display-messages` | Display LSP progress messages below statusline[^1] | `false` |
-| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` |
-| `display-inlay-hints` | Display inlay hints[^2] | `false` |
-| `display-signature-help-docs` | Display docs under signature help popup | `true` |
-| `snippets` | Enables snippet completions. Requires a server restart (`:lsp-restart`) to take effect after `:config-reload`/`:set`. | `true` |
-| `goto-reference-include-declaration` | Include declaration in the goto references popup. | `true` |
+| Key | Description | Default |
+| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | ------- |
+| `enable` | Enables LSP integration. Setting to false will completely disable language servers regardless of language settings. | `true` |
+| `display-messages` | Display LSP progress messages below statusline[^1] | `false` |
+| `auto-signature-help` | Enable automatic popup of signature help (parameter hints) | `true` |
+| `display-inlay-hints` | Display inlay hints[^2] | `false` |
+| `display-signature-help-docs` | Display docs under signature help popup | `true` |
+| `snippets` | Enables snippet completions. Requires a server restart (`:lsp-restart`) to take effect after `:config-reload`/`:set`. | `true` |
+| `goto-reference-include-declaration` | Include declaration in the goto references popup. | `true` |
[^1]: By default, a progress spinner is shown in the statusline beside the file path.
-
[^2]: You may also have to activate them in the LSP config for them to appear, not just in Helix. Inlay hints in Helix are still being improved on and may be a little bit laggy/janky under some circumstances. Please report any bugs you see so we can fix them!
### `[editor.cursor-shape]` Section
@@ -147,7 +147,7 @@ Valid values for these options are `block`, `bar`, `underline`, or `hidden`.
> change shape.
| Key | Description | Default |
-| --- | ----------- | ------- |
+| -------- | ------------------------------------------ | ------- |
| `normal` | Cursor shape in [normal mode][normal mode] | `block` |
| `insert` | Cursor shape in [insert mode][insert mode] | `block` |
| `select` | Cursor shape in [select mode][select mode] | `block` |
@@ -163,17 +163,17 @@ not visible in the Helix file picker and global search.
All git related options are only enabled in a git repository.
-| Key | Description | Default |
-|--|--|---------|
-|`hidden` | Enables ignoring hidden files | true
-|`follow-symlinks` | Follow symlinks instead of ignoring them | true
-|`deduplicate-links` | Ignore symlinks that point at files already shown in the picker | true
-|`parents` | Enables reading ignore files from parent directories | true
-|`ignore` | Enables reading `.ignore` files | true
-|`git-ignore` | Enables reading `.gitignore` files | true
-|`git-global` | Enables reading global `.gitignore`, whose path is specified in git's config: `core.excludefile` option | true
-|`git-exclude` | Enables reading `.git/info/exclude` files | true
-|`max-depth` | Set with an integer value for maximum depth to recurse | Defaults to `None`.
+| Key | Description | Default |
+| ------------------- | ------------------------------------------------------------------------------------------------------- | ------------------- |
+| `hidden` | Enables ignoring hidden files | true |
+| `follow-symlinks` | Follow symlinks instead of ignoring them | true |
+| `deduplicate-links` | Ignore symlinks that point at files already shown in the picker | true |
+| `parents` | Enables reading ignore files from parent directories | true |
+| `ignore` | Enables reading `.ignore` files | true |
+| `git-ignore` | Enables reading `.gitignore` files | true |
+| `git-global` | Enables reading global `.gitignore`, whose path is specified in git's config: `core.excludefile` option | true |
+| `git-exclude` | Enables reading `.git/info/exclude` files | true |
+| `max-depth` | Set with an integer value for maximum depth to recurse | Defaults to `None`. |
### `[editor.auto-pairs]` Section
@@ -223,19 +223,19 @@ name = "rust"
Search specific options.
-| Key | Description | Default |
-|--|--|---------|
-| `smart-case` | Enable smart case regex searching (case-insensitive unless pattern contains upper case characters) | `true` |
-| `wrap-around`| Whether the search should wrap after depleting the matches | `true` |
+| Key | Description | Default |
+| ------------- | -------------------------------------------------------------------------------------------------- | ------- |
+| `smart-case` | Enable smart case regex searching (case-insensitive unless pattern contains upper case characters) | `true` |
+| `wrap-around` | Whether the search should wrap after depleting the matches | `true` |
### `[editor.whitespace]` Section
Options for rendering whitespace with visible characters. Use `:set whitespace.render all` to temporarily enable visible whitespace.
-| Key | Description | Default |
-|-----|-------------|---------|
-| `render` | Whether to render whitespace. May either be `"all"` or `"none"`, or a table with sub-keys `space`, `nbsp`, `tab`, and `newline` | `"none"` |
-| `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `newline` or `tabpad` | See example below |
+| Key | Description | Default |
+| ------------ | ------------------------------------------------------------------------------------------------------------------------------- | ----------------- |
+| `render` | Whether to render whitespace. May either be `"all"` or `"none"`, or a table with sub-keys `space`, `nbsp`, `tab`, and `newline` | `"none"` |
+| `characters` | Literal characters to use when rendering whitespace. Sub-keys may be any of `tab`, `space`, `nbsp`, `newline` or `tabpad` | See example below |
Example
@@ -261,7 +261,7 @@ tabpad = "·" # Tabs will look like "→···" (depending on tab width)
Options for rendering vertical indent guides.
| Key | Description | Default |
-| --- | --- | --- |
+| ------------- | ------------------------------------------------------- | ------- |
| `render` | Whether to render indent guides | `false` |
| `character` | Literal character to use for rendering the indent guide | `│` |
| `skip-levels` | Number of indent levels to skip | `0` |
@@ -290,7 +290,7 @@ be used. This section contains top level settings, as well as settings for
specific gutter components as subsections.
| Key | Description | Default |
-| --- | --- | --- |
+| -------- | ------------------------------ | ------------------------------------------------------------- |
| `layout` | A vector of gutters to display | `["diagnostics", "spacer", "line-numbers", "spacer", "diff"]` |
Example:
@@ -305,7 +305,7 @@ layout = ["diff", "diagnostics", "line-numbers", "spacer"]
Options for the line number gutter
| Key | Description | Default |
-| --- | --- | --- |
+| ----------- | --------------------------------------- | ------- |
| `min-width` | The minimum number of characters to use | `3` |
Example:
@@ -331,13 +331,13 @@ Currently unused
Options for soft wrapping lines that exceed the view width:
-| Key | Description | Default |
-| --- | --- | --- |
-| `enable` | Whether soft wrapping is enabled. | `false` |
-| `max-wrap` | Maximum free space left at the end of the line. | `20` |
-| `max-indent-retain` | Maximum indentation to carry over when soft wrapping a line. | `40` |
+| Key | Description | Default |
+| -------------------- | --------------------------------------------------------------------------- | ------- |
+| `enable` | Whether soft wrapping is enabled. | `false` |
+| `max-wrap` | Maximum free space left at the end of the line. | `20` |
+| `max-indent-retain` | Maximum indentation to carry over when soft wrapping a line. | `40` |
| `wrap-indicator` | Text inserted before soft wrapped lines, highlighted with `ui.virtual.wrap` | `↪ ` |
-| `wrap-at-text-width` | Soft wrap at `text-width` instead of using the full viewport size. | `false` |
+| `wrap-at-text-width` | Soft wrap at `text-width` instead of using the full viewport size. | `false` |
Example:
@@ -351,8 +351,16 @@ wrap-indicator = "" # set wrap-indicator to "" to hide it
### `[editor.smart-tab]` Section
-
-| Key | Description | Default |
-|------------|-------------|---------|
-| `enable` | If set to true, then when the cursor is in a position with non-whitespace to its left, instead of inserting a tab, it will run `move_parent_node_end`. If there is only whitespace to the left, then it inserts a tab as normal. With the default bindings, to explicitly insert a tab character, press Shift-tab. | `true` |
+| Key | Description | Default |
+| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| `enable` | If set to true, then when the cursor is in a position with non-whitespace to its left, instead of inserting a tab, it will run `move_parent_node_end`. If there is only whitespace to the left, then it inserts a tab as normal. With the default bindings, to explicitly insert a tab character, press Shift-tab. | `true` |
| `supersede-menu` | Normally, when a menu is on screen, such as when auto complete is triggered, the tab key is bound to cycling through the items. This means when menus are on screen, one cannot use the tab key to trigger the `smart-tab` command. If this option is set to true, the `smart-tab` command always takes precedence, which means one cannot use the tab key to cycle through menu items. One of the other bindings must be used instead, such as arrow keys or `C-n`/`C-p`. | `false` |
+
+### `[editor.explorer]` Section
+
+Sets explorer side width and style.
+
+| Key | Description | Default |
+| -------------- | ------------------------------------------- | ------- |
+| `column-width` | explorer side width | 30 |
+| `position` | explorer widget position, `left` or `right` | `left` |
diff --git a/book/src/keymap.md b/book/src/keymap.md
index 0f41b3247f3d..ceedf65a588b 100644
--- a/book/src/keymap.md
+++ b/book/src/keymap.md
@@ -296,6 +296,8 @@ This layer is a kludge of mappings, mostly pickers.
| `R` | Replace selections by clipboard contents | `replace_selections_with_clipboard` |
| `/` | Global search in workspace folder | `global_search` |
| `?` | Open command palette | `command_palette` |
+| `e` | Reveal current file in explorer | `reveal_current_file` |
+
> 💡 Global search displays results in a fuzzy picker, use `Space + '` to bring it back up after opening a file.
@@ -452,3 +454,7 @@ Keys to use within prompt, Remapping currently not supported.
| `Tab` | Select next completion item |
| `BackTab` | Select previous completion item |
| `Enter` | Open selected |
+
+# File explorer
+
+Press `?` to see keymaps. Remapping currently not supported.
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 1f88079eb27d..a4dea3d9ea9e 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -492,6 +492,8 @@ impl MappableCommand {
record_macro, "Record macro",
replay_macro, "Replay macro",
command_palette, "Open command palette",
+ open_or_focus_explorer, "Open or focus explorer",
+ reveal_current_file, "Reveal current file in explorer",
);
}
@@ -2623,6 +2625,49 @@ fn file_picker_in_current_directory(cx: &mut Context) {
cx.push_layer(Box::new(overlaid(picker)));
}
+fn open_or_focus_explorer(cx: &mut Context) {
+ cx.callback = Some(Box::new(
+ |compositor: &mut Compositor, cx: &mut compositor::Context| {
+ if let Some(editor) = compositor.find::() {
+ match editor.explorer.as_mut() {
+ Some(explore) => explore.focus(),
+ None => match ui::Explorer::new(cx) {
+ Ok(explore) => editor.explorer = Some(explore),
+ Err(err) => cx.editor.set_error(format!("{}", err)),
+ },
+ }
+ }
+ },
+ ));
+}
+
+fn reveal_file(cx: &mut Context, path: Option) {
+ cx.callback = Some(Box::new(
+ |compositor: &mut Compositor, cx: &mut compositor::Context| {
+ if let Some(editor) = compositor.find::() {
+ (|| match editor.explorer.as_mut() {
+ Some(explorer) => match path {
+ Some(path) => explorer.reveal_file(path),
+ None => explorer.reveal_current_file(cx),
+ },
+ None => {
+ editor.explorer = Some(ui::Explorer::new(cx)?);
+ if let Some(explorer) = editor.explorer.as_mut() {
+ explorer.reveal_current_file(cx)?;
+ }
+ Ok(())
+ }
+ })()
+ .unwrap_or_else(|err| cx.editor.set_error(err.to_string()))
+ }
+ },
+ ));
+}
+
+fn reveal_current_file(cx: &mut Context) {
+ reveal_file(cx, None)
+}
+
fn buffer_picker(cx: &mut Context) {
let current = view!(cx.editor).doc;
diff --git a/helix-term/src/compositor.rs b/helix-term/src/compositor.rs
index bcb3e44904e4..ea6510c250fe 100644
--- a/helix-term/src/compositor.rs
+++ b/helix-term/src/compositor.rs
@@ -34,6 +34,47 @@ impl<'a> Context<'a> {
tokio::task::block_in_place(|| helix_lsp::block_on(self.editor.flush_writes()))?;
Ok(())
}
+
+ /// Purpose: to test `handle_event` without escalating the test case to integration test
+ /// Usage:
+ /// ```
+ /// let mut editor = Context::dummy_editor();
+ /// let mut jobs = Context::dummy_jobs();
+ /// let mut cx = Context::dummy(&mut jobs, &mut editor);
+ /// ```
+ #[cfg(test)]
+ pub fn dummy(jobs: &'a mut Jobs, editor: &'a mut helix_view::Editor) -> Context<'a> {
+ Context {
+ jobs,
+ scroll: None,
+ editor,
+ }
+ }
+
+ #[cfg(test)]
+ pub fn dummy_jobs() -> Jobs {
+ Jobs::new()
+ }
+
+ #[cfg(test)]
+ pub fn dummy_editor() -> Editor {
+ use crate::config::Config;
+ use arc_swap::{access::Map, ArcSwap};
+ use helix_core::syntax::{self, Configuration};
+ use helix_view::theme;
+ use std::sync::Arc;
+
+ let config = Arc::new(ArcSwap::from_pointee(Config::default()));
+ Editor::new(
+ Rect::new(0, 0, 60, 120),
+ Arc::new(theme::Loader::new(&[])),
+ Arc::new(syntax::Loader::new(Configuration { language: vec![] })),
+ Arc::new(Arc::new(Map::new(
+ Arc::clone(&config),
+ |config: &Config| &config.editor,
+ ))),
+ )
+ }
}
pub trait Component: Any + AnyComponent {
@@ -72,6 +113,21 @@ pub trait Component: Any + AnyComponent {
fn id(&self) -> Option<&'static str> {
None
}
+
+ #[cfg(test)]
+ /// Utility method for testing `handle_event` without using integration test.
+ /// Especially useful for testing helper components such as `Prompt`, `TreeView` etc
+ fn handle_events(&mut self, events: &str) -> anyhow::Result<()> {
+ use helix_view::input::parse_macro;
+
+ let mut editor = Context::dummy_editor();
+ let mut jobs = Context::dummy_jobs();
+ let mut cx = Context::dummy(&mut jobs, &mut editor);
+ for event in parse_macro(events)? {
+ self.handle_event(&Event::Key(event), &mut cx);
+ }
+ Ok(())
+ }
}
pub struct Compositor {
diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs
index 763ed4ae71ce..58e8fdad8817 100644
--- a/helix-term/src/keymap/default.rs
+++ b/helix-term/src/keymap/default.rs
@@ -277,6 +277,7 @@ pub fn default() -> HashMap {
"r" => rename_symbol,
"h" => select_references_to_symbol_under_cursor,
"?" => command_palette,
+ "e" => reveal_current_file,
},
"z" => { "View"
"z" | "c" => align_view_center,
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index aa159d40dce5..ac68f9b16bb4 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -6,7 +6,7 @@ use crate::{
keymap::{KeymapResult, Keymaps},
ui::{
document::{render_document, LinePos, TextRenderer, TranslatedPosition},
- Completion, ProgressSpinners,
+ Completion, Explorer, ProgressSpinners,
},
};
@@ -23,7 +23,7 @@ use helix_core::{
};
use helix_view::{
document::{Mode, SavePoint, SCRATCH_BUFFER_NAME},
- editor::{CompleteAction, CursorShapeConfig},
+ editor::{CompleteAction, CursorShapeConfig, ExplorerPosition},
graphics::{Color, CursorKind, Modifier, Rect, Style},
input::{KeyEvent, MouseButton, MouseEvent, MouseEventKind},
keyboard::{KeyCode, KeyModifiers},
@@ -43,6 +43,7 @@ pub struct EditorView {
pub(crate) last_insert: (commands::MappableCommand, Vec),
pub(crate) completion: Option,
spinners: ProgressSpinners,
+ pub(crate) explorer: Option,
}
#[derive(Debug, Clone)]
@@ -71,6 +72,7 @@ impl EditorView {
last_insert: (commands::MappableCommand::normal_mode, Vec::new()),
completion: None,
spinners: ProgressSpinners::default(),
+ explorer: None,
}
}
@@ -1227,6 +1229,11 @@ impl Component for EditorView {
event: &Event,
context: &mut crate::compositor::Context,
) -> EventResult {
+ if let Some(explore) = self.explorer.as_mut() {
+ if let EventResult::Consumed(callback) = explore.handle_event(event, context) {
+ return EventResult::Consumed(callback);
+ }
+ }
let mut cx = commands::Context {
editor: context.editor,
count: None,
@@ -1389,6 +1396,8 @@ impl Component for EditorView {
surface.set_style(area, cx.editor.theme.get("ui.background"));
let config = cx.editor.config();
+ let editor_area = area.clip_bottom(1);
+
// check if bufferline should be rendered
use helix_view::editor::BufferLine;
let use_bufferline = match config.bufferline {
@@ -1397,15 +1406,43 @@ impl Component for EditorView {
_ => false,
};
- // -1 for commandline and -1 for bufferline
- let mut editor_area = area.clip_bottom(1);
- if use_bufferline {
- editor_area = editor_area.clip_top(1);
- }
+ let editor_area = if use_bufferline {
+ editor_area.clip_top(1)
+ } else {
+ editor_area
+ };
+
+ let editor_area = if let Some(explorer) = &self.explorer {
+ let explorer_column_width = if explorer.is_opened() {
+ explorer.column_width().saturating_add(2)
+ } else {
+ 0
+ };
+ // For future developer:
+ // We should have a Dock trait that allows a component to dock to the top/left/bottom/right
+ // of another component.
+ match config.explorer.position {
+ ExplorerPosition::Left => editor_area.clip_left(explorer_column_width),
+ ExplorerPosition::Right => editor_area.clip_right(explorer_column_width),
+ }
+ } else {
+ editor_area
+ };
// if the terminal size suddenly changed, we need to trigger a resize
cx.editor.resize(editor_area);
+ if let Some(explorer) = self.explorer.as_mut() {
+ if !explorer.is_focus() {
+ let area = if use_bufferline {
+ area.clip_top(1)
+ } else {
+ area
+ };
+ explorer.render(area, surface, cx);
+ }
+ }
+
if use_bufferline {
Self::render_bufferline(cx.editor, area.with_height(1), surface);
}
@@ -1484,9 +1521,28 @@ impl Component for EditorView {
if let Some(completion) = self.completion.as_mut() {
completion.render(area, surface, cx);
}
+
+ if let Some(explore) = self.explorer.as_mut() {
+ if explore.is_focus() {
+ let area = if use_bufferline {
+ area.clip_top(1)
+ } else {
+ area
+ };
+ explore.render(area, surface, cx);
+ }
+ }
}
fn cursor(&self, _area: Rect, editor: &Editor) -> (Option, CursorKind) {
+ if let Some(explore) = &self.explorer {
+ if explore.is_focus() {
+ let cursor = explore.cursor(_area, editor);
+ if cursor.0.is_some() {
+ return cursor;
+ }
+ }
+ }
match editor.cursor() {
// All block cursors are drawn manually
(pos, CursorKind::Block) => (pos, CursorKind::Hidden),
diff --git a/helix-term/src/ui/explorer.rs b/helix-term/src/ui/explorer.rs
new file mode 100644
index 000000000000..4ad8dee72a21
--- /dev/null
+++ b/helix-term/src/ui/explorer.rs
@@ -0,0 +1,1464 @@
+use super::{Prompt, TreeOp, TreeView, TreeViewItem};
+use crate::{
+ compositor::{Component, Context, EventResult},
+ ctrl, key, shift, ui,
+};
+use anyhow::{bail, ensure, Result};
+use helix_core::Position;
+use helix_view::{
+ editor::{Action, ExplorerPosition},
+ graphics::{CursorKind, Rect},
+ info::Info,
+ input::{Event, KeyEvent},
+ theme::Modifier,
+ Editor,
+};
+use std::cmp::Ordering;
+use std::path::{Path, PathBuf};
+use std::{borrow::Cow, fs::DirEntry};
+use tui::{
+ buffer::Buffer as Surface,
+ widgets::{Block, Borders, Widget},
+};
+
+#[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Copy)]
+enum FileType {
+ File,
+ Folder,
+ Root,
+}
+
+#[derive(PartialEq, Eq, Debug, Clone)]
+struct FileInfo {
+ file_type: FileType,
+ path: PathBuf,
+}
+
+impl FileInfo {
+ fn root(path: PathBuf) -> Self {
+ Self {
+ file_type: FileType::Root,
+ path,
+ }
+ }
+
+ fn get_text(&self) -> Cow<'static, str> {
+ let text = match self.file_type {
+ FileType::Root => self.path.display().to_string(),
+ FileType::File | FileType::Folder => self
+ .path
+ .file_name()
+ .map_or("/".into(), |p| p.to_string_lossy().into_owned()),
+ };
+
+ #[cfg(test)]
+ let text = text.replace(std::path::MAIN_SEPARATOR, "/");
+
+ text.into()
+ }
+}
+
+impl PartialOrd for FileInfo {
+ fn partial_cmp(&self, other: &Self) -> Option {
+ Some(self.cmp(other))
+ }
+}
+
+impl Ord for FileInfo {
+ fn cmp(&self, other: &Self) -> Ordering {
+ use FileType::*;
+ match (self.file_type, other.file_type) {
+ (Root, _) => return Ordering::Less,
+ (_, Root) => return Ordering::Greater,
+ _ => {}
+ };
+
+ if let (Some(p1), Some(p2)) = (self.path.parent(), other.path.parent()) {
+ if p1 == p2 {
+ match (self.file_type, other.file_type) {
+ (Folder, File) => return Ordering::Less,
+ (File, Folder) => return Ordering::Greater,
+ _ => {}
+ };
+ }
+ }
+ self.path.cmp(&other.path)
+ }
+}
+
+impl TreeViewItem for FileInfo {
+ type Params = State;
+
+ fn get_children(&self) -> Result> {
+ match self.file_type {
+ FileType::Root | FileType::Folder => {}
+ _ => return Ok(vec![]),
+ };
+ let ret: Vec<_> = std::fs::read_dir(&self.path)?
+ .filter_map(|entry| entry.ok())
+ .filter_map(|entry| dir_entry_to_file_info(entry, &self.path))
+ .collect();
+ Ok(ret)
+ }
+
+ fn name(&self) -> String {
+ self.get_text().to_string()
+ }
+
+ fn is_parent(&self) -> bool {
+ matches!(self.file_type, FileType::Folder | FileType::Root)
+ }
+}
+
+fn dir_entry_to_file_info(entry: DirEntry, path: &Path) -> Option {
+ entry.metadata().ok().map(|meta| {
+ let file_type = match meta.is_dir() {
+ true => FileType::Folder,
+ false => FileType::File,
+ };
+ FileInfo {
+ file_type,
+ path: path.join(entry.file_name()),
+ }
+ })
+}
+
+#[derive(Clone, Debug)]
+enum PromptAction {
+ CreateFileOrFolder,
+ RemoveFolder,
+ RemoveFile,
+ RenameFile,
+}
+
+#[derive(Clone, Debug, Default)]
+struct State {
+ focus: bool,
+ open: bool,
+ current_root: PathBuf,
+ area_width: u16,
+}
+
+impl State {
+ fn new(focus: bool, current_root: PathBuf) -> Self {
+ Self {
+ focus,
+ current_root,
+ open: true,
+ area_width: 0,
+ }
+ }
+}
+
+struct ExplorerHistory {
+ tree: TreeView,
+ current_root: PathBuf,
+}
+
+pub struct Explorer {
+ tree: TreeView,
+ history: Vec,
+ show_help: bool,
+ state: State,
+ prompt: Option<(PromptAction, Prompt)>,
+ #[allow(clippy::type_complexity)]
+ on_next_key: Option EventResult>>,
+ column_width: u16,
+}
+
+impl Explorer {
+ pub fn new(cx: &mut Context) -> Result {
+ let current_root = std::env::current_dir()
+ .unwrap_or_else(|_| "./".into())
+ .canonicalize()?;
+ Ok(Self {
+ tree: Self::new_tree_view(current_root.clone())?,
+ history: vec![],
+ show_help: false,
+ state: State::new(true, current_root),
+ prompt: None,
+ on_next_key: None,
+ column_width: cx.editor.config().explorer.column_width as u16,
+ })
+ }
+
+ #[cfg(test)]
+ fn from_path(root: PathBuf, column_width: u16) -> Result {
+ Ok(Self {
+ tree: Self::new_tree_view(root.clone())?,
+ history: vec![],
+ show_help: false,
+ state: State::new(true, root),
+ prompt: None,
+ on_next_key: None,
+ column_width,
+ })
+ }
+
+ fn new_tree_view(root: PathBuf) -> Result> {
+ let root = FileInfo::root(root);
+ Ok(TreeView::build_tree(root)?.with_enter_fn(Self::toggle_current))
+ }
+
+ fn push_history(&mut self, tree_view: TreeView, current_root: PathBuf) {
+ self.history.push(ExplorerHistory {
+ tree: tree_view,
+ current_root,
+ });
+ const MAX_HISTORY_SIZE: usize = 20;
+ Vec::truncate(&mut self.history, MAX_HISTORY_SIZE)
+ }
+
+ fn change_root(&mut self, root: PathBuf) -> Result<()> {
+ if self.state.current_root.eq(&root) {
+ return Ok(());
+ }
+ let tree = Self::new_tree_view(root.clone())?;
+ let old_tree = std::mem::replace(&mut self.tree, tree);
+ self.push_history(old_tree, self.state.current_root.clone());
+ self.state.current_root = root;
+ Ok(())
+ }
+
+ pub fn reveal_file(&mut self, path: PathBuf) -> Result<()> {
+ let current_root = &self.state.current_root.canonicalize()?;
+ let current_path = &path.canonicalize()?;
+ let segments = {
+ let stripped = match current_path.strip_prefix(current_root) {
+ Ok(stripped) => Ok(stripped),
+ Err(_) => {
+ let parent = path.parent().ok_or_else(|| {
+ anyhow::anyhow!("Failed get parent of '{}'", current_path.to_string_lossy())
+ })?;
+ self.change_root(parent.into())?;
+ current_path
+ .strip_prefix(parent.canonicalize()?)
+ .map_err(|_| {
+ anyhow::anyhow!(
+ "Failed to strip prefix (parent) '{}' from '{}'",
+ parent.to_string_lossy(),
+ current_path.to_string_lossy()
+ )
+ })
+ }
+ }?;
+
+ stripped
+ .components()
+ .map(|c| c.as_os_str().to_string_lossy().to_string())
+ .collect::>()
+ };
+ self.tree.reveal_item(segments)?;
+ Ok(())
+ }
+
+ pub fn reveal_current_file(&mut self, cx: &mut Context) -> Result<()> {
+ self.focus();
+ let current_document_path = doc!(cx.editor).path().cloned();
+ match current_document_path {
+ None => Ok(()),
+ Some(current_path) => self.reveal_file(current_path),
+ }
+ }
+
+ pub fn focus(&mut self) {
+ self.state.focus = true;
+ self.state.open = true;
+ }
+
+ fn unfocus(&mut self) {
+ self.state.focus = false;
+ }
+
+ fn close(&mut self) {
+ self.state.focus = false;
+ self.state.open = false;
+ }
+
+ pub fn is_focus(&self) -> bool {
+ self.state.focus
+ }
+
+ fn new_create_file_or_folder_prompt(&mut self, cx: &mut Context) -> Result<()> {
+ let folder_path = self.nearest_folder()?;
+ self.prompt = Some((
+ PromptAction::CreateFileOrFolder,
+ Prompt::new(
+ format!(
+ " New file or folder (ends with '{}'): ",
+ std::path::MAIN_SEPARATOR
+ )
+ .into(),
+ None,
+ ui::completers::none,
+ |_, _, _| {},
+ )
+ .with_line(format!("{}/", folder_path.to_string_lossy()), cx.editor),
+ ));
+ Ok(())
+ }
+
+ fn nearest_folder(&self) -> Result {
+ let current = self.tree.current()?.item();
+ if current.is_parent() {
+ Ok(current.path.to_path_buf())
+ } else {
+ let parent_path = current.path.parent().ok_or_else(|| {
+ anyhow::anyhow!(format!(
+ "Unable to get parent path of '{}'",
+ current.path.to_string_lossy()
+ ))
+ })?;
+ Ok(parent_path.to_path_buf())
+ }
+ }
+
+ fn new_remove_prompt(&mut self) -> Result<()> {
+ let item = self.tree.current()?.item();
+ match item.file_type {
+ FileType::Folder => self.new_remove_folder_prompt(),
+ FileType::File => self.new_remove_file_prompt(),
+ FileType::Root => bail!("Root is not removable"),
+ }
+ }
+
+ fn new_rename_prompt(&mut self, cx: &mut Context) -> Result<()> {
+ let path = self.tree.current_item()?.path.clone();
+ self.prompt = Some((
+ PromptAction::RenameFile,
+ Prompt::new(
+ " Rename to ".into(),
+ None,
+ ui::completers::none,
+ |_, _, _| {},
+ )
+ .with_line(path.to_string_lossy().to_string(), cx.editor),
+ ));
+ Ok(())
+ }
+
+ fn new_remove_file_prompt(&mut self) -> Result<()> {
+ let item = self.tree.current_item()?;
+ ensure!(
+ item.path.is_file(),
+ "The path '{}' is not a file",
+ item.path.to_string_lossy()
+ );
+ self.prompt = Some((
+ PromptAction::RemoveFile,
+ Prompt::new(
+ format!(" Delete file: '{}'? y/N: ", item.path.display()).into(),
+ None,
+ ui::completers::none,
+ |_, _, _| {},
+ ),
+ ));
+ Ok(())
+ }
+
+ fn new_remove_folder_prompt(&mut self) -> Result<()> {
+ let item = self.tree.current_item()?;
+ ensure!(
+ item.path.is_dir(),
+ "The path '{}' is not a folder",
+ item.path.to_string_lossy()
+ );
+
+ self.prompt = Some((
+ PromptAction::RemoveFolder,
+ Prompt::new(
+ format!(" Delete folder: '{}'? y/N: ", item.path.display()).into(),
+ None,
+ ui::completers::none,
+ |_, _, _| {},
+ ),
+ ));
+ Ok(())
+ }
+
+ fn toggle_current(item: &mut FileInfo, cx: &mut Context, state: &mut State) -> TreeOp {
+ (|| -> Result {
+ if item.path == Path::new("") {
+ return Ok(TreeOp::Noop);
+ }
+ let meta = std::fs::metadata(&item.path)?;
+ if meta.is_file() {
+ cx.editor.open(&item.path, Action::Replace)?;
+ state.focus = false;
+ return Ok(TreeOp::Noop);
+ }
+
+ if item.path.is_dir() {
+ return Ok(TreeOp::GetChildsAndInsert);
+ }
+
+ Err(anyhow::anyhow!("Unknown file type: {:?}", meta.file_type()))
+ })()
+ .unwrap_or_else(|err| {
+ cx.editor.set_error(format!("{err}"));
+ TreeOp::Noop
+ })
+ }
+
+ fn render_tree(
+ &mut self,
+ area: Rect,
+ prompt_area: Rect,
+ surface: &mut Surface,
+ cx: &mut Context,
+ ) {
+ self.tree.render(area, prompt_area, surface, cx);
+ }
+
+ fn render_embed(
+ &mut self,
+ area: Rect,
+ surface: &mut Surface,
+ cx: &mut Context,
+ position: &ExplorerPosition,
+ ) {
+ if !self.state.open {
+ return;
+ }
+ let width = area.width.min(self.column_width + 2);
+
+ self.state.area_width = area.width;
+
+ let side_area = match position {
+ ExplorerPosition::Left => Rect { width, ..area },
+ ExplorerPosition::Right => Rect {
+ x: area.width - width,
+ width,
+ ..area
+ },
+ }
+ .clip_bottom(1);
+ let background = cx.editor.theme.get("ui.background");
+ surface.clear_with(side_area, background);
+
+ let prompt_area = area.clip_top(side_area.height);
+
+ let list_area = match position {
+ ExplorerPosition::Left => {
+ render_block(side_area.clip_left(1), surface, Borders::RIGHT).clip_bottom(1)
+ }
+ ExplorerPosition::Right => {
+ render_block(side_area.clip_right(1), surface, Borders::LEFT).clip_bottom(1)
+ }
+ };
+ self.render_tree(list_area, prompt_area, surface, cx);
+
+ {
+ let statusline = if self.is_focus() {
+ cx.editor.theme.get("ui.statusline")
+ } else {
+ cx.editor.theme.get("ui.statusline.inactive")
+ };
+ let area = side_area.clip_top(list_area.height);
+ let area = match position {
+ ExplorerPosition::Left => area.clip_right(1),
+ ExplorerPosition::Right => area.clip_left(1),
+ };
+ surface.clear_with(area, statusline);
+
+ let title_style = cx.editor.theme.get("ui.text");
+ let title_style = if self.is_focus() {
+ title_style.add_modifier(Modifier::BOLD)
+ } else {
+ title_style
+ };
+ surface.set_stringn(
+ area.x,
+ area.y,
+ if self.is_focus() {
+ " EXPLORER: press ? for help"
+ } else {
+ " EXPLORER"
+ },
+ area.width.into(),
+ title_style,
+ );
+ }
+
+ if self.is_focus() && self.show_help {
+ let help_area = match position {
+ ExplorerPosition::Left => area,
+ ExplorerPosition::Right => area.clip_right(list_area.width.saturating_add(2)),
+ };
+ self.render_help(help_area, surface, cx);
+ }
+
+ if let Some((_, prompt)) = self.prompt.as_mut() {
+ prompt.render_prompt(prompt_area, surface, cx)
+ }
+ }
+
+ fn render_help(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+ Info::new(
+ "Explorer",
+ &[
+ ("?", "Toggle help"),
+ ("a", "Add file/folder"),
+ ("r", "Rename file/folder"),
+ ("d", "Delete file"),
+ ("B", "Change root to parent folder"),
+ ("]", "Change root to current folder"),
+ ("[", "Go to previous root"),
+ ("+, =", "Increase size"),
+ ("-, _", "Decrease size"),
+ ("q", "Close"),
+ ]
+ .into_iter()
+ .chain(ui::tree::tree_view_help().into_iter())
+ .collect::>(),
+ )
+ .render(area, surface, cx)
+ }
+
+ fn handle_prompt_event(&mut self, event: &KeyEvent, cx: &mut Context) -> EventResult {
+ let result = (|| -> Result {
+ let (action, mut prompt) = match self.prompt.take() {
+ Some((action, p)) => (action, p),
+ _ => return Ok(EventResult::Ignored(None)),
+ };
+ let line = prompt.line();
+
+ let current_item_path = self.tree.current_item()?.path.clone();
+ match (&action, event) {
+ (PromptAction::CreateFileOrFolder, key!(Enter)) => {
+ if line.ends_with(std::path::MAIN_SEPARATOR) {
+ self.new_folder(line)?
+ } else {
+ self.new_file(line)?
+ }
+ }
+ (PromptAction::RemoveFolder, key) => {
+ if let key!('y') = key {
+ close_documents(current_item_path, cx)?;
+ self.remove_folder()?;
+ }
+ }
+ (PromptAction::RemoveFile, key) => {
+ if let key!('y') = key {
+ close_documents(current_item_path, cx)?;
+ self.remove_file()?;
+ }
+ }
+ (PromptAction::RenameFile, key!(Enter)) => {
+ close_documents(current_item_path, cx)?;
+ self.rename_current(line)?;
+ }
+ (_, key!(Esc) | ctrl!('c')) => {}
+ _ => {
+ prompt.handle_event(&Event::Key(*event), cx);
+ self.prompt = Some((action, prompt));
+ }
+ }
+ Ok(EventResult::Consumed(None))
+ })();
+ match result {
+ Ok(event_result) => event_result,
+ Err(err) => {
+ cx.editor.set_error(err.to_string());
+ EventResult::Consumed(None)
+ }
+ }
+ }
+
+ fn new_file(&mut self, path: &str) -> Result<()> {
+ let path = helix_core::path::get_normalized_path(&PathBuf::from(path));
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let mut fd = std::fs::OpenOptions::new();
+ fd.create_new(true).write(true).open(&path)?;
+ self.tree.refresh()?;
+ self.reveal_file(path)
+ }
+
+ fn new_folder(&mut self, path: &str) -> Result<()> {
+ let path = helix_core::path::get_normalized_path(&PathBuf::from(path));
+ std::fs::create_dir_all(&path)?;
+ self.tree.refresh()?;
+ self.reveal_file(path)
+ }
+
+ fn toggle_help(&mut self) {
+ self.show_help = !self.show_help
+ }
+
+ fn go_to_previous_root(&mut self) {
+ if let Some(history) = self.history.pop() {
+ self.tree = history.tree;
+ self.state.current_root = history.current_root
+ }
+ }
+
+ fn change_root_to_current_folder(&mut self) -> Result<()> {
+ self.change_root(self.tree.current_item()?.path.clone())
+ }
+
+ fn change_root_parent_folder(&mut self) -> Result<()> {
+ if let Some(parent) = self.state.current_root.parent() {
+ let path = parent.to_path_buf();
+ self.change_root(path)
+ } else {
+ Ok(())
+ }
+ }
+
+ pub fn is_opened(&self) -> bool {
+ self.state.open
+ }
+
+ pub fn column_width(&self) -> u16 {
+ self.column_width
+ }
+
+ fn increase_size(&mut self) {
+ const EDITOR_MIN_WIDTH: u16 = 10;
+ self.column_width = std::cmp::min(
+ self.state.area_width.saturating_sub(EDITOR_MIN_WIDTH),
+ self.column_width.saturating_add(1),
+ )
+ }
+
+ fn decrease_size(&mut self) {
+ self.column_width = self.column_width.saturating_sub(1)
+ }
+
+ fn rename_current(&mut self, line: &String) -> Result<()> {
+ let item = self.tree.current_item()?;
+ let path = PathBuf::from(line);
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ std::fs::rename(&item.path, &path)?;
+ self.tree.refresh()?;
+ self.reveal_file(path)
+ }
+
+ fn remove_folder(&mut self) -> Result<()> {
+ let item = self.tree.current_item()?;
+ std::fs::remove_dir_all(&item.path)?;
+ self.tree.refresh()
+ }
+
+ fn remove_file(&mut self) -> Result<()> {
+ let item = self.tree.current_item()?;
+ std::fs::remove_file(&item.path)?;
+ self.tree.refresh()
+ }
+}
+
+fn close_documents(current_item_path: PathBuf, cx: &mut Context) -> Result<()> {
+ let ids = cx
+ .editor
+ .documents
+ .iter()
+ .filter_map(|(id, doc)| {
+ if doc.path()?.starts_with(¤t_item_path) {
+ Some(*id)
+ } else {
+ None
+ }
+ })
+ .collect::>();
+
+ for id in ids {
+ cx.editor.close_document(id, true)?;
+ }
+ Ok(())
+}
+
+impl Component for Explorer {
+ /// Process input events, return true if handled.
+ fn handle_event(&mut self, event: &Event, cx: &mut Context) -> EventResult {
+ if self.tree.prompting() {
+ return self.tree.handle_event(event, cx, &mut self.state);
+ }
+ let key_event = match event {
+ Event::Key(event) => event,
+ Event::Resize(..) => return EventResult::Consumed(None),
+ _ => return EventResult::Ignored(None),
+ };
+ if !self.is_focus() {
+ return EventResult::Ignored(None);
+ }
+ if let Some(mut on_next_key) = self.on_next_key.take() {
+ return on_next_key(cx, self, key_event);
+ }
+
+ if let EventResult::Consumed(c) = self.handle_prompt_event(key_event, cx) {
+ return EventResult::Consumed(c);
+ }
+
+ (|| -> Result<()> {
+ match key_event {
+ key!(Esc) => self.unfocus(),
+ key!('q') => self.close(),
+ key!('?') => self.toggle_help(),
+ key!('a') => self.new_create_file_or_folder_prompt(cx)?,
+ shift!('B') => self.change_root_parent_folder()?,
+ key!(']') => self.change_root_to_current_folder()?,
+ key!('[') => self.go_to_previous_root(),
+ key!('d') => self.new_remove_prompt()?,
+ key!('r') => self.new_rename_prompt(cx)?,
+ key!('-') | key!('_') => self.decrease_size(),
+ key!('+') | key!('=') => self.increase_size(),
+ _ => {
+ self.tree
+ .handle_event(&Event::Key(*key_event), cx, &mut self.state);
+ }
+ };
+ Ok(())
+ })()
+ .unwrap_or_else(|err| cx.editor.set_error(format!("{err}")));
+
+ EventResult::Consumed(None)
+ }
+
+ fn render(&mut self, area: Rect, surface: &mut Surface, cx: &mut Context) {
+ if area.width < 10 || area.height < 5 {
+ cx.editor.set_error("explorer render area is too small");
+ return;
+ }
+ let config = &cx.editor.config().explorer;
+ let position = config.position;
+ self.render_embed(area, surface, cx, &position);
+ }
+
+ fn cursor(&self, area: Rect, editor: &Editor) -> (Option, CursorKind) {
+ if let Some(prompt) = self
+ .prompt
+ .as_ref()
+ .map(|(_, prompt)| prompt)
+ .or_else(|| self.tree.prompt())
+ {
+ let (x, y) = (area.x, area.y + area.height.saturating_sub(1));
+ prompt.cursor(Rect::new(x, y, area.width, 1), editor)
+ } else {
+ (None, CursorKind::Hidden)
+ }
+ }
+}
+
+fn render_block(area: Rect, surface: &mut Surface, borders: Borders) -> Rect {
+ let block = Block::default().borders(borders);
+ let inner = block.inner(area);
+ block.render(area, surface);
+ inner
+}
+
+#[cfg(test)]
+mod test_explorer {
+ use crate::compositor::Component;
+
+ use super::Explorer;
+ use helix_view::graphics::Rect;
+ use std::{fs, path::PathBuf};
+
+ /// This code should create the following file tree:
+ ///
+ ///
+ /// ├── index.html
+ /// ├── .gitignore
+ /// ├── scripts
+ /// │ └── main.js
+ /// └── styles
+ /// ├── style.css
+ /// └── public
+ /// └── file
+ ///
+ fn dummy_file_tree() -> PathBuf {
+ let path = tempfile::tempdir().unwrap().path().to_path_buf();
+ if path.exists() {
+ fs::remove_dir_all(path.clone()).unwrap();
+ }
+ fs::create_dir_all(path.clone()).unwrap();
+ fs::write(path.join("index.html"), "").unwrap();
+ fs::write(path.join(".gitignore"), "").unwrap();
+
+ fs::create_dir_all(path.join("scripts")).unwrap();
+ fs::write(path.join("scripts").join("main.js"), "").unwrap();
+
+ fs::create_dir_all(path.join("styles")).unwrap();
+ fs::write(path.join("styles").join("style.css"), "").unwrap();
+
+ fs::create_dir_all(path.join("styles").join("public")).unwrap();
+ fs::write(path.join("styles").join("public").join("file"), "").unwrap();
+
+ path
+ }
+
+ fn render(explorer: &mut Explorer) -> String {
+ explorer.tree.render_to_string(Rect::new(0, 0, 100, 10))
+ }
+
+ fn new_explorer() -> (PathBuf, Explorer) {
+ let path = dummy_file_tree();
+ (path.clone(), Explorer::from_path(path, 100).unwrap())
+ }
+
+ #[test]
+ fn test_reveal_file() {
+ let (path, mut explorer) = new_explorer();
+
+ let path_str = path.display().to_string();
+
+ // 0a. Expect the "scripts" folder is not opened
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+({path_str})
+⏵ scripts
+⏵ styles
+ .gitignore
+ index.html
+"
+ )
+ .trim()
+ );
+
+ // 1. Reveal "scripts/main.js"
+ explorer.reveal_file(path.join("scripts/main.js")).unwrap();
+
+ // 1a. Expect the "scripts" folder is opened, and "main.js" is focused
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏷ [scripts]
+ (main.js)
+⏵ styles
+ .gitignore
+ index.html
+"
+ )
+ .trim()
+ );
+
+ // 2. Change root to "scripts"
+ explorer.tree.move_up(1);
+ explorer.change_root_to_current_folder().unwrap();
+
+ // 2a. Expect the current root is "scripts"
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+({path_str}/scripts)
+ main.js
+"
+ )
+ .trim()
+ );
+
+ // 3. Reveal "styles/public/file", which is outside of the current root
+ explorer
+ .reveal_file(path.join("styles/public/file"))
+ .unwrap();
+
+ // 3a. Expect the current root is "public", and "file" is focused
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}/styles/public]
+ (file)
+"
+ )
+ .trim()
+ );
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_rename() {
+ let (path, mut explorer) = new_explorer();
+ let path_str = path.display().to_string();
+
+ explorer.handle_events("jjj").unwrap();
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏵ styles
+ (.gitignore)
+ index.html
+"
+ )
+ .trim()
+ );
+
+ // 0. Open the rename file prompt
+ explorer.handle_events("r").unwrap();
+
+ // 0.1 Expect the current prompt to be related to file renaming
+ let prompt = &explorer.prompt.as_ref().unwrap().1;
+ assert_eq!(prompt.prompt(), " Rename to ");
+ assert_eq!(
+ prompt.line().replace(std::path::MAIN_SEPARATOR, "/"),
+ format!("{path_str}/.gitignore")
+ );
+
+ // 1. Rename the current file to a name that is lexicographically greater than "index.html"
+ explorer.handle_events("who.is").unwrap();
+
+ // 1a. Expect the file is renamed, and is focused
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏵ styles
+ index.html
+ (who.is)
+"
+ )
+ .trim()
+ );
+
+ assert!(path.join("who.is").exists());
+
+ // 2. Rename the current file into an existing folder
+ explorer
+ .handle_events(&format!(
+ "rstyles{}lol",
+ std::path::MAIN_SEPARATOR
+ ))
+ .unwrap();
+
+ // 2a. Expect the file is moved to the folder, and is focused
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏷ [styles]
+ ⏵ public
+ (lol)
+ style.css
+ index.html
+"
+ )
+ .trim()
+ );
+
+ assert!(path.join("styles/lol").exists());
+
+ // 3. Rename the current file into a non-existent folder
+ explorer
+ .handle_events(&format!(
+ "r{}",
+ path.join("new_folder/sponge/bob").display()
+ ))
+ .unwrap();
+
+ // 3a. Expect the non-existent folder to be created,
+ // and the file is moved into it,
+ // and the renamed file is focused
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏷ [new_folder]
+ ⏷ [sponge]
+ (bob)
+⏵ scripts
+⏷ styles
+ ⏵ public
+ style.css
+ index.html
+"
+ )
+ .trim()
+ );
+
+ assert!(path.join("new_folder/sponge/bob").exists());
+
+ // 4. Change current root to "new_folder/sponge"
+ explorer.handle_events("k]").unwrap();
+
+ // 4a. Expect the current root to be "sponge"
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+({path_str}/new_folder/sponge)
+ bob
+"
+ )
+ .trim()
+ );
+
+ // 5. Move cursor to "bob", and rename it outside of the current root
+ explorer.handle_events("j").unwrap();
+ explorer
+ .handle_events(&format!(
+ "r{}",
+ path.join("scripts/bob").display()
+ ))
+ .unwrap();
+
+ // 5a. Expect the current root to be "scripts"
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}/scripts]
+ (bob)
+ main.js
+"
+ )
+ .trim()
+ );
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_new_folder() {
+ let (path, mut explorer) = new_explorer();
+ let path_str = path.display().to_string();
+
+ // 0. Open the add file/folder prompt
+ explorer.handle_events("a").unwrap();
+ let prompt = &explorer.prompt.as_ref().unwrap().1;
+ fn to_forward_slash(s: &str) -> String {
+ s.replace(std::path::MAIN_SEPARATOR, "/")
+ }
+ fn to_os_main_separator(s: &str) -> String {
+ s.replace('/', format!("{}", std::path::MAIN_SEPARATOR).as_str())
+ }
+ assert_eq!(
+ to_forward_slash(prompt.prompt()),
+ " New file or folder (ends with '/'): "
+ );
+ assert_eq!(to_forward_slash(prompt.line()), format!("{path_str}/"));
+
+ // 1. Add a new folder at the root
+ explorer
+ .handle_events(&to_os_main_separator("yoyo/"))
+ .unwrap();
+
+ // 1a. Expect the new folder is added, and is focused
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏵ styles
+⏷ (yoyo)
+ .gitignore
+ index.html
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_dir(path.join("yoyo")).is_ok());
+
+ // 2. Move up to "styles"
+ explorer.handle_events("k").unwrap();
+
+ // 3. Add a new folder
+ explorer
+ .handle_events(&to_os_main_separator("asus.sass/"))
+ .unwrap();
+
+ // 3a. Expect the new folder is added under "styles", although "styles" is not opened
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏷ [styles]
+ ⏵ public
+ ⏷ (sus.sass)
+ style.css
+⏷ yoyo
+ .gitignore
+ index.html
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_dir(path.join("styles/sus.sass")).is_ok());
+
+ // 4. Add a new folder with non-existent parents
+ explorer
+ .handle_events(&to_os_main_separator("aa/b/c/"))
+ .unwrap();
+
+ // 4a. Expect the non-existent parents are created,
+ // and the new folder is created,
+ // and is focused
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏷ [styles]
+ ⏷ [sus.sass]
+ ⏷ [a]
+ ⏷ [b]
+ ⏷ (c)
+ style.css
+⏷ yoyo
+ .gitignore
+ index.html
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_dir(path.join("styles/sus.sass/a/b/c")).is_ok());
+
+ // 5. Move to "style.css"
+ explorer.handle_events("j").unwrap();
+
+ // 6. Add a new folder here
+ explorer
+ .handle_events(&to_os_main_separator("afoobar/"))
+ .unwrap();
+
+ // 6a. Expect the folder is added under "styles",
+ // because the folder of the current item, "style.css" is "styles/"
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏷ [styles]
+ ⏷ (foobar)
+ ⏵ public
+ ⏷ sus.sass
+ ⏷ a
+ ⏷ b
+ ⏷ c
+ style.css
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_dir(path.join("styles/foobar")).is_ok());
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_new_file() {
+ let (path, mut explorer) = new_explorer();
+ let path_str = path.display().to_string();
+
+ // 1. Add a new file at the root
+ explorer.handle_events("ayoyo").unwrap();
+
+ // 1a. Expect the new file is added, and is focused
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏵ styles
+ .gitignore
+ index.html
+ (yoyo)
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_to_string(path.join("yoyo")).is_ok());
+
+ // 2. Move up to "styles"
+ explorer.tree.move_up(3);
+
+ // 3. Add a new file
+ explorer.handle_events("asus.sass").unwrap();
+
+ // 3a. Expect the new file is added under "styles", although "styles" is not opened
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏷ [styles]
+ ⏵ public
+ style.css
+ (sus.sass)
+ .gitignore
+ index.html
+ yoyo
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_to_string(path.join("styles/sus.sass")).is_ok());
+
+ // 4. Add a new file with non-existent parents
+ explorer.handle_events("aa/b/c").unwrap();
+
+ // 4a. Expect the non-existent parents are created,
+ // and the new file is created,
+ // and is focused
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏷ [styles]
+ ⏷ [a]
+ ⏷ [b]
+ (c)
+ ⏵ public
+ style.css
+ sus.sass
+ .gitignore
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_to_string(path.join("styles/a/b/c")).is_ok());
+
+ // 5. Move to "style.css"
+ explorer.handle_events("jj").unwrap();
+
+ // 6. Add a new file here
+ explorer.handle_events("afoobar").unwrap();
+
+ // 6a. Expect the file is added under "styles",
+ // because the folder of the current item, "style.css" is "styles/"
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏷ [styles]
+ ⏷ b
+ c
+ ⏵ public
+ (foobar)
+ style.css
+ sus.sass
+ .gitignore
+ index.html
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_to_string(path.join("styles/foobar")).is_ok());
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_remove_file() {
+ let (path, mut explorer) = new_explorer();
+ let path_str = path.display().to_string();
+
+ // 1. Move to ".gitignore"
+ explorer.handle_events("/.gitignore").unwrap();
+
+ // 1a. Expect the cursor is at ".gitignore"
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏵ styles
+ (.gitignore)
+ index.html
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_to_string(path.join(".gitignore")).is_ok());
+
+ // 2. Remove the current file
+ explorer.handle_events("dy").unwrap();
+
+ // 3. Expect ".gitignore" is deleted, and the cursor moved down
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏵ styles
+ (index.html)
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_to_string(path.join(".gitignore")).is_err());
+
+ // 3a. Expect "index.html" exists
+ assert!(fs::read_to_string(path.join("index.html")).is_ok());
+
+ // 4. Remove the current file
+ explorer.handle_events("dy").unwrap();
+
+ // 4a. Expect "index.html" is deleted, at the cursor moved up
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏵ (styles)
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_to_string(path.join("index.html")).is_err());
+ }
+
+ #[tokio::test(flavor = "multi_thread")]
+ async fn test_remove_folder() {
+ let (path, mut explorer) = new_explorer();
+ let path_str = path.display().to_string();
+
+ // 1. Move to "styles/"
+ explorer.handle_events("/styleso").unwrap();
+
+ // 1a. Expect the cursor is at "styles"
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏷ (styles)
+ ⏵ public
+ style.css
+ .gitignore
+ index.html
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_dir(path.join("styles")).is_ok());
+
+ // 2. Remove the current folder
+ explorer.handle_events("dy").unwrap();
+
+ // 3. Expect "styles" is deleted, and the cursor moved down
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+ (.gitignore)
+ index.html
+"
+ )
+ .trim()
+ );
+
+ assert!(fs::read_dir(path.join("styles")).is_err());
+ }
+
+ #[test]
+ fn test_change_root() {
+ let (path, mut explorer) = new_explorer();
+ let path_str = path.display().to_string();
+
+ // 1. Move cursor to "styles"
+ explorer.reveal_file(path.join("styles")).unwrap();
+
+ // 2. Change root to current folder, and move cursor down
+ explorer.change_root_to_current_folder().unwrap();
+ explorer.tree.move_down(1);
+
+ // 2a. Expect the current root to be "styles", and the cursor is at "public"
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}/styles]
+⏵ (public)
+ style.css
+"
+ )
+ .trim()
+ );
+
+ let current_root = explorer.state.current_root.clone();
+
+ // 3. Change root to the parent of current folder
+ explorer.change_root_parent_folder().unwrap();
+
+ // 3a. Expect the current root to be "change_root"
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+({path_str})
+⏵ scripts
+⏵ styles
+ .gitignore
+ index.html
+"
+ )
+ .trim()
+ );
+
+ // 4. Go back to previous root
+ explorer.go_to_previous_root();
+
+ // 4a. Expect the root te become "styles", and the cursor position is not forgotten
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}/styles]
+⏵ (public)
+ style.css
+"
+ )
+ .trim()
+ );
+ assert_eq!(explorer.state.current_root, current_root);
+
+ // 5. Go back to previous root again
+ explorer.go_to_previous_root();
+
+ // 5a. Expect the current root to be "change_root" again,
+ // but this time the "styles" folder is opened,
+ // because it was opened before any change of root
+ assert_eq!(
+ render(&mut explorer),
+ format!(
+ "
+[{path_str}]
+⏵ scripts
+⏷ (styles)
+ ⏵ public
+ style.css
+ .gitignore
+ index.html
+"
+ )
+ .trim()
+ );
+ }
+}
diff --git a/helix-term/src/ui/mod.rs b/helix-term/src/ui/mod.rs
index 2d15fb32fa8c..7e39e5b52cb5 100644
--- a/helix-term/src/ui/mod.rs
+++ b/helix-term/src/ui/mod.rs
@@ -1,6 +1,7 @@
mod completion;
mod document;
pub(crate) mod editor;
+mod explorer;
mod fuzzy_match;
mod info;
pub mod lsp;
@@ -13,12 +14,14 @@ mod prompt;
mod spinner;
mod statusline;
mod text;
+mod tree;
use crate::compositor::{Component, Compositor};
use crate::filter_picker_entry;
use crate::job::{self, Callback};
pub use completion::{Completion, CompletionItem};
pub use editor::EditorView;
+pub use explorer::Explorer;
pub use markdown::Markdown;
pub use menu::Menu;
pub use picker::{DynamicPicker, FileLocation, Picker};
@@ -26,6 +29,7 @@ pub use popup::Popup;
pub use prompt::{Prompt, PromptEvent};
pub use spinner::{ProgressSpinners, Spinner};
pub use text::Text;
+pub use tree::{TreeOp, TreeView, TreeViewItem};
use helix_core::regex::Regex;
use helix_core::regex::RegexBuilder;
diff --git a/helix-term/src/ui/overlay.rs b/helix-term/src/ui/overlay.rs
index ff184d4073fa..56252c71c862 100644
--- a/helix-term/src/ui/overlay.rs
+++ b/helix-term/src/ui/overlay.rs
@@ -19,26 +19,7 @@ pub struct Overlay {
pub fn overlaid(content: T) -> Overlay {
Overlay {
content,
- calc_child_size: Box::new(|rect: Rect| clip_rect_relative(rect.clip_bottom(2), 90, 90)),
- }
-}
-
-fn clip_rect_relative(rect: Rect, percent_horizontal: u8, percent_vertical: u8) -> Rect {
- fn mul_and_cast(size: u16, factor: u8) -> u16 {
- ((size as u32) * (factor as u32) / 100).try_into().unwrap()
- }
-
- let inner_w = mul_and_cast(rect.width, percent_horizontal);
- let inner_h = mul_and_cast(rect.height, percent_vertical);
-
- let offset_x = rect.width.saturating_sub(inner_w) / 2;
- let offset_y = rect.height.saturating_sub(inner_h) / 2;
-
- Rect {
- x: rect.x + offset_x,
- y: rect.y + offset_y,
- width: inner_w,
- height: inner_h,
+ calc_child_size: Box::new(|rect: Rect| rect.overlayed()),
}
}
diff --git a/helix-term/src/ui/prompt.rs b/helix-term/src/ui/prompt.rs
index 702a6e6714ad..c883e7bf4245 100644
--- a/helix-term/src/ui/prompt.rs
+++ b/helix-term/src/ui/prompt.rs
@@ -98,6 +98,9 @@ impl Prompt {
self
}
+ pub fn prompt(&self) -> &str {
+ self.prompt.as_ref()
+ }
pub fn with_language(mut self, language: &'static str, loader: Arc) -> Self {
self.language = Some((language, loader));
self
diff --git a/helix-term/src/ui/tree.rs b/helix-term/src/ui/tree.rs
new file mode 100644
index 000000000000..d0a9af5bf4c8
--- /dev/null
+++ b/helix-term/src/ui/tree.rs
@@ -0,0 +1,2681 @@
+use std::cmp::Ordering;
+
+use anyhow::Result;
+use helix_view::theme::Modifier;
+
+use crate::{
+ compositor::{Component, Context, EventResult},
+ ctrl, key, shift, ui,
+};
+use helix_core::movement::Direction;
+use helix_view::{
+ graphics::Rect,
+ input::{Event, KeyEvent},
+};
+use tui::buffer::Buffer as Surface;
+
+use super::Prompt;
+
+pub trait TreeViewItem: Sized + Ord {
+ type Params: Default;
+
+ fn name(&self) -> String;
+ fn is_parent(&self) -> bool;
+
+ fn filter(&self, s: &str) -> bool {
+ self.name().to_lowercase().contains(&s.to_lowercase())
+ }
+
+ fn get_children(&self) -> Result>;
+}
+
+fn tree_item_cmp(item1: &T, item2: &T) -> Ordering {
+ T::cmp(item1, item2)
+}
+
+fn vec_to_tree(mut items: Vec) -> Vec> {
+ items.sort();
+ index_elems(
+ 0,
+ items
+ .into_iter()
+ .map(|item| Tree::new(item, vec![]))
+ .collect(),
+ )
+}
+
+pub enum TreeOp {
+ Noop,
+ GetChildsAndInsert,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct Tree {
+ item: T,
+ parent_index: Option,
+ index: usize,
+ children: Vec,
+
+ /// Why do we need this property?
+ /// Can't we just use `!children.is_empty()`?
+ ///
+ /// Because we might have for example an open folder that is empty,
+ /// and user just added a new file under that folder,
+ /// and the user refreshes the whole tree.
+ ///
+ /// Without `open`, we will not refresh any node without children,
+ /// and thus the folder still appears empty after refreshing.
+ is_opened: bool,
+}
+
+impl Clone for Tree {
+ fn clone(&self) -> Self {
+ Self {
+ item: self.item.clone(),
+ index: self.index,
+ children: self.children.clone(),
+ is_opened: self.is_opened,
+ parent_index: self.parent_index,
+ }
+ }
+}
+
+#[derive(Clone)]
+struct TreeIter<'a, T> {
+ current_index_forward: usize,
+ current_index_reverse: isize,
+ tree: &'a Tree,
+}
+
+impl<'a, T> Iterator for TreeIter<'a, T> {
+ type Item = &'a Tree;
+
+ fn next(&mut self) -> Option {
+ let index = self.current_index_forward;
+ if index > self.tree.len().saturating_sub(1) {
+ None
+ } else {
+ self.current_index_forward = self.current_index_forward.saturating_add(1);
+ self.tree.get(index)
+ }
+ }
+
+ fn size_hint(&self) -> (usize, Option) {
+ (self.tree.len(), Some(self.tree.len()))
+ }
+}
+
+impl<'a, T> DoubleEndedIterator for TreeIter<'a, T> {
+ fn next_back(&mut self) -> Option {
+ let index = self.current_index_reverse;
+ if index < 0 {
+ None
+ } else {
+ self.current_index_reverse = self.current_index_reverse.saturating_sub(1);
+ self.tree.get(index as usize)
+ }
+ }
+}
+
+impl<'a, T> ExactSizeIterator for TreeIter<'a, T> {}
+
+impl Tree {
+ fn open(&mut self) -> Result<()> {
+ if self.item.is_parent() {
+ self.children = self.get_children()?;
+ self.is_opened = true;
+ }
+ Ok(())
+ }
+
+ fn close(&mut self) {
+ self.is_opened = false;
+ self.children = vec![];
+ }
+
+ fn refresh(&mut self) -> Result<()> {
+ if !self.is_opened {
+ return Ok(());
+ }
+ let latest_children = self.get_children()?;
+ let filtered = std::mem::take(&mut self.children)
+ .into_iter()
+ // Remove children that does not exists in latest_children
+ .filter(|tree| {
+ latest_children
+ .iter()
+ .any(|child| tree.item.name().eq(&child.item.name()))
+ })
+ .map(|mut tree| {
+ tree.refresh()?;
+ Ok(tree)
+ })
+ .collect::>>()?;
+
+ // Add new children
+ let new_nodes = latest_children
+ .into_iter()
+ .filter(|child| {
+ !filtered
+ .iter()
+ .any(|child_| child.item.name().eq(&child_.item.name()))
+ })
+ .collect::>();
+
+ self.children = filtered.into_iter().chain(new_nodes).collect();
+
+ self.sort();
+
+ self.regenerate_index();
+
+ Ok(())
+ }
+
+ fn get_children(&self) -> Result>> {
+ Ok(vec_to_tree(self.item.get_children()?))
+ }
+
+ fn sort(&mut self) {
+ self.children
+ .sort_by(|a, b| tree_item_cmp(&a.item, &b.item))
+ }
+}
+
+impl Tree {
+ pub fn new(item: T, children: Vec>) -> Self {
+ let is_opened = !children.is_empty();
+ Self {
+ item,
+ index: 0,
+ parent_index: None,
+ children: index_elems(0, children),
+ is_opened,
+ }
+ }
+
+ fn iter(&self) -> TreeIter {
+ TreeIter {
+ tree: self,
+ current_index_forward: 0,
+ current_index_reverse: (self.len() - 1) as isize,
+ }
+ }
+
+ /// Find an element in the tree with given `predicate`.
+ /// `start_index` is inclusive if direction is `Forward`.
+ /// `start_index` is exclusive if direction is `Backward`.
+ fn find(&self, start_index: usize, direction: Direction, predicate: F) -> Option
+ where
+ F: Clone + FnMut(&Tree) -> bool,
+ {
+ match direction {
+ Direction::Forward => match self
+ .iter()
+ .skip(start_index)
+ .position(predicate.clone())
+ .map(|index| index + start_index)
+ {
+ Some(index) => Some(index),
+ None => self.iter().position(predicate),
+ },
+
+ Direction::Backward => match self.iter().take(start_index).rposition(predicate.clone())
+ {
+ Some(index) => Some(index),
+ None => self.iter().rposition(predicate),
+ },
+ }
+ }
+
+ pub fn item(&self) -> &T {
+ &self.item
+ }
+
+ fn get(&self, index: usize) -> Option<&Tree> {
+ if self.index == index {
+ Some(self)
+ } else {
+ self.children.iter().find_map(|elem| elem.get(index))
+ }
+ }
+
+ fn get_mut(&mut self, index: usize) -> Option<&mut Tree> {
+ if self.index == index {
+ Some(self)
+ } else {
+ self.children
+ .iter_mut()
+ .find_map(|elem| elem.get_mut(index))
+ }
+ }
+
+ fn len(&self) -> usize {
+ (1_usize).saturating_add(self.children.iter().map(|elem| elem.len()).sum())
+ }
+
+ fn regenerate_index(&mut self) {
+ let items = std::mem::take(&mut self.children);
+ self.children = index_elems(0, items);
+ }
+}
+
+#[derive(Clone, Debug)]
+struct SavedView {
+ selected: usize,
+ winline: usize,
+}
+
+pub struct TreeView {
+ tree: Tree,
+
+ search_prompt: Option<(Direction, Prompt)>,
+
+ search_str: String,
+
+ /// Selected item idex
+ selected: usize,
+
+ backward_jumps: Vec,
+ forward_jumps: Vec,
+
+ saved_view: Option,
+
+ /// For implementing vertical scroll
+ winline: usize,
+
+ /// For implementing horizontal scoll
+ column: usize,
+
+ /// For implementing horizontal scoll
+ max_len: usize,
+ count: usize,
+ tree_symbol_style: String,
+
+ #[allow(clippy::type_complexity)]
+ pre_render: Option>,
+
+ #[allow(clippy::type_complexity)]
+ on_opened_fn: Option TreeOp + 'static>>,
+
+ #[allow(clippy::type_complexity)]
+ on_folded_fn: Option>,
+
+ #[allow(clippy::type_complexity)]
+ on_next_key: Option Result<()>>>,
+}
+
+impl TreeView {
+ pub fn build_tree(root: T) -> Result {
+ let children = root.get_children()?;
+ let items = vec_to_tree(children);
+ Ok(Self {
+ tree: Tree::new(root, items),
+ selected: 0,
+ backward_jumps: vec![],
+ forward_jumps: vec![],
+ saved_view: None,
+ winline: 0,
+ column: 0,
+ max_len: 0,
+ count: 0,
+ tree_symbol_style: "ui.text".into(),
+ pre_render: None,
+ on_opened_fn: None,
+ on_folded_fn: None,
+ on_next_key: None,
+ search_prompt: None,
+ search_str: "".into(),
+ })
+ }
+
+ pub fn with_enter_fn(mut self, f: F) -> Self
+ where
+ F: FnMut(&mut T, &mut Context, &mut T::Params) -> TreeOp + 'static,
+ {
+ self.on_opened_fn = Some(Box::new(f));
+ self
+ }
+
+ pub fn with_folded_fn(mut self, f: F) -> Self
+ where
+ F: FnMut(&mut T, &mut Context, &mut T::Params) + 'static,
+ {
+ self.on_folded_fn = Some(Box::new(f));
+ self
+ }
+
+ pub fn tree_symbol_style(mut self, style: String) -> Self {
+ self.tree_symbol_style = style;
+ self
+ }
+
+ /// Reveal item in the tree based on the given `segments`.
+ ///
+ /// The name of the root should be excluded.
+ ///
+ /// Example `segments`:
+ ///
+ /// vec!["helix-term", "src", "ui", "tree.rs"]
+ ///
+ pub fn reveal_item(&mut self, segments: Vec) -> Result<()> {
+ // Expand the tree
+ let root = self.tree.item.name();
+ segments.iter().fold(
+ Ok(&mut self.tree),
+ |current_tree, segment| match current_tree {
+ Err(err) => Err(err),
+ Ok(current_tree) => {
+ match current_tree
+ .children
+ .iter_mut()
+ .find(|tree| tree.item.name().eq(segment))
+ {
+ Some(tree) => {
+ if !tree.is_opened {
+ tree.open()?;
+ }
+ Ok(tree)
+ }
+ None => Err(anyhow::anyhow!(format!(
+ "Unable to find path: '{}'. current_segment = '{segment}'. current_root = '{root}'",
+ segments.join("/"),
+ ))),
+ }
+ }
+ },
+ )?;
+
+ // Locate the item
+ self.regenerate_index();
+ self.set_selected(
+ segments
+ .iter()
+ .fold(&self.tree, |tree, segment| {
+ tree.children
+ .iter()
+ .find(|tree| tree.item.name().eq(segment))
+ .expect("Should be unreachable")
+ })
+ .index,
+ );
+
+ self.align_view_center();
+ Ok(())
+ }
+
+ fn align_view_center(&mut self) {
+ self.pre_render = Some(Box::new(|tree, area| {
+ tree.winline = area.height as usize / 2
+ }))
+ }
+
+ fn align_view_top(&mut self) {
+ self.winline = 0
+ }
+
+ fn align_view_bottom(&mut self) {
+ self.pre_render = Some(Box::new(|tree, area| tree.winline = area.height as usize))
+ }
+
+ fn regenerate_index(&mut self) {
+ self.tree.regenerate_index();
+ }
+
+ fn move_to_parent(&mut self) -> Result<()> {
+ if let Some(parent) = self.current_parent()? {
+ let index = parent.index;
+ self.set_selected(index)
+ }
+ Ok(())
+ }
+
+ fn move_to_children(&mut self) -> Result<()> {
+ let current = self.current_mut()?;
+ if current.is_opened {
+ self.set_selected(self.selected + 1);
+ Ok(())
+ } else {
+ current.open()?;
+ if !current.children.is_empty() {
+ self.set_selected(self.selected + 1);
+ self.regenerate_index();
+ }
+ Ok(())
+ }
+ }
+
+ pub fn refresh(&mut self) -> Result<()> {
+ self.tree.refresh()?;
+ self.set_selected(self.selected);
+ Ok(())
+ }
+
+ fn move_to_first_line(&mut self) {
+ self.move_up(usize::MAX / 2)
+ }
+
+ fn move_to_last_line(&mut self) {
+ self.move_down(usize::MAX / 2)
+ }
+
+ fn move_leftmost(&mut self) {
+ self.move_left(usize::MAX / 2);
+ }
+
+ fn move_rightmost(&mut self) {
+ self.move_right(usize::MAX / 2)
+ }
+
+ fn restore_saved_view(&mut self) -> Result<()> {
+ if let Some(saved_view) = self.saved_view.take() {
+ self.selected = saved_view.selected;
+ self.winline = saved_view.winline;
+ self.refresh()
+ } else {
+ Ok(())
+ }
+ }
+
+ pub fn prompt(&self) -> Option<&Prompt> {
+ if let Some((_, prompt)) = self.search_prompt.as_ref() {
+ Some(prompt)
+ } else {
+ None
+ }
+ }
+}
+
+pub fn tree_view_help() -> Vec<(&'static str, &'static str)> {
+ vec![
+ ("o, Enter", "Open/Close"),
+ ("j, down, C-n", "Down"),
+ ("k, up, C-p", "Up"),
+ ("h, left", "Go to parent"),
+ ("l, right", "Expand"),
+ ("J", "Go to next sibling"),
+ ("K", "Go to previous sibling"),
+ ("H", "Go to first child"),
+ ("L", "Go to last child"),
+ ("R", "Refresh"),
+ ("/", "Search"),
+ ("n", "Go to next search match"),
+ ("N", "Go to previous search match"),
+ ("gh, Home", "Scroll to the leftmost"),
+ ("gl, End", "Scroll to the rightmost"),
+ ("C-o", "Jump backward"),
+ ("C-i, Tab", "Jump forward"),
+ ("C-d", "Half page down"),
+ ("C-u", "Half page up"),
+ ("PageDown", "Full page down"),
+ ("PageUp", "Full page up"),
+ ("zt", "Align view top"),
+ ("zz", "Align view center"),
+ ("zb", "Align view bottom"),
+ ("gg", "Go to first line"),
+ ("ge", "Go to last line"),
+ ]
+}
+
+impl TreeView {
+ pub fn on_enter(
+ &mut self,
+ cx: &mut Context,
+ params: &mut T::Params,
+ selected_index: usize,
+ ) -> Result<()> {
+ let selected_item = self.get_mut(selected_index)?;
+ if selected_item.is_opened {
+ selected_item.close();
+ self.regenerate_index();
+ return Ok(());
+ }
+
+ if let Some(mut on_open_fn) = self.on_opened_fn.take() {
+ let mut f = || -> Result<()> {
+ let current = self.current_mut()?;
+ match on_open_fn(&mut current.item, cx, params) {
+ TreeOp::GetChildsAndInsert => {
+ if let Err(err) = current.open() {
+ cx.editor.set_error(format!("{err}"))
+ }
+ }
+ TreeOp::Noop => {}
+ };
+ Ok(())
+ };
+ f()?;
+ self.regenerate_index();
+ self.on_opened_fn = Some(on_open_fn);
+ };
+ Ok(())
+ }
+
+ fn set_search_str(&mut self, s: String) {
+ self.search_str = s;
+ self.saved_view = None;
+ }
+
+ fn saved_view(&self) -> SavedView {
+ self.saved_view.clone().unwrap_or(SavedView {
+ selected: self.selected,
+ winline: self.winline,
+ })
+ }
+
+ fn search_next(&mut self, s: &str) {
+ let saved_view = self.saved_view();
+ let skip = std::cmp::max(2, saved_view.selected + 1);
+ self.set_selected(
+ self.tree
+ .find(skip, Direction::Forward, |e| e.item.filter(s))
+ .unwrap_or(saved_view.selected),
+ );
+ }
+
+ fn search_previous(&mut self, s: &str) {
+ let saved_view = self.saved_view();
+ let take = saved_view.selected;
+ self.set_selected(
+ self.tree
+ .find(take, Direction::Backward, |e| e.item.filter(s))
+ .unwrap_or(saved_view.selected),
+ );
+ }
+
+ fn move_to_next_search_match(&mut self) {
+ self.search_next(&self.search_str.clone())
+ }
+
+ fn move_to_previous_next_match(&mut self) {
+ self.search_previous(&self.search_str.clone())
+ }
+
+ pub fn move_down(&mut self, rows: usize) {
+ self.set_selected(self.selected.saturating_add(rows))
+ }
+
+ fn set_selected(&mut self, selected: usize) {
+ let previous_selected = self.selected;
+ self.set_selected_without_history(selected);
+ if previous_selected.abs_diff(selected) > 1 {
+ self.backward_jumps.push(previous_selected)
+ }
+ }
+
+ fn set_selected_without_history(&mut self, selected: usize) {
+ let selected = selected.clamp(0, self.tree.len().saturating_sub(1));
+ if selected > self.selected {
+ // Move down
+ self.winline = selected.min(
+ self.winline
+ .saturating_add(selected.saturating_sub(self.selected)),
+ );
+ } else {
+ // Move up
+ self.winline = selected.min(
+ self.winline
+ .saturating_sub(self.selected.saturating_sub(selected)),
+ );
+ }
+ self.selected = selected
+ }
+
+ fn jump_backward(&mut self) {
+ if let Some(index) = self.backward_jumps.pop() {
+ self.forward_jumps.push(self.selected);
+ self.set_selected_without_history(index);
+ }
+ }
+
+ fn jump_forward(&mut self) {
+ if let Some(index) = self.forward_jumps.pop() {
+ self.set_selected(index)
+ }
+ }
+
+ pub fn move_up(&mut self, rows: usize) {
+ self.set_selected(self.selected.saturating_sub(rows))
+ }
+
+ fn move_to_next_sibling(&mut self) -> Result<()> {
+ if let Some(parent) = self.current_parent()? {
+ if let Some(local_index) = parent
+ .children
+ .iter()
+ .position(|child| child.index == self.selected)
+ {
+ if let Some(next_sibling) = parent.children.get(local_index.saturating_add(1)) {
+ self.set_selected(next_sibling.index)
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn move_to_previous_sibling(&mut self) -> Result<()> {
+ if let Some(parent) = self.current_parent()? {
+ if let Some(local_index) = parent
+ .children
+ .iter()
+ .position(|child| child.index == self.selected)
+ {
+ if let Some(next_sibling) = parent.children.get(local_index.saturating_sub(1)) {
+ self.set_selected(next_sibling.index)
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn move_to_last_sibling(&mut self) -> Result<()> {
+ if let Some(parent) = self.current_parent()? {
+ if let Some(last) = parent.children.last() {
+ self.set_selected(last.index)
+ }
+ }
+ Ok(())
+ }
+
+ fn move_to_first_sibling(&mut self) -> Result<()> {
+ if let Some(parent) = self.current_parent()? {
+ if let Some(last) = parent.children.first() {
+ self.set_selected(last.index)
+ }
+ }
+ Ok(())
+ }
+
+ fn move_left(&mut self, cols: usize) {
+ self.column = self.column.saturating_sub(cols);
+ }
+
+ fn move_right(&mut self, cols: usize) {
+ self.pre_render = Some(Box::new(move |tree, area| {
+ let max_scroll = tree
+ .max_len
+ .saturating_sub(area.width as usize)
+ .saturating_add(1);
+ tree.column = max_scroll.min(tree.column + cols);
+ }));
+ }
+
+ fn move_down_half_page(&mut self) {
+ self.pre_render = Some(Box::new(|tree, area| {
+ tree.move_down((area.height / 2) as usize);
+ }));
+ }
+
+ fn move_up_half_page(&mut self) {
+ self.pre_render = Some(Box::new(|tree, area| {
+ tree.move_up((area.height / 2) as usize);
+ }));
+ }
+
+ fn move_down_page(&mut self) {
+ self.pre_render = Some(Box::new(|tree, area| {
+ tree.move_down((area.height) as usize);
+ }));
+ }
+
+ fn move_up_page(&mut self) {
+ self.pre_render = Some(Box::new(|tree, area| {
+ tree.move_up((area.height) as usize);
+ }));
+ }
+
+ fn save_view(&mut self) {
+ self.saved_view = Some(SavedView {
+ selected: self.selected,
+ winline: self.winline,
+ })
+ }
+
+ fn get(&self, index: usize) -> Result<&Tree> {
+ self.tree.get(index).ok_or_else(|| {
+ anyhow::anyhow!("Programming error: TreeView.get: index {index} is out of bound")
+ })
+ }
+
+ fn get_mut(&mut self, index: usize) -> Result<&mut Tree> {
+ self.tree.get_mut(index).ok_or_else(|| {
+ anyhow::anyhow!("Programming error: TreeView.get_mut: index {index} is out of bound")
+ })
+ }
+
+ pub fn current(&self) -> Result<&Tree> {
+ self.get(self.selected)
+ }
+
+ pub fn current_mut(&mut self) -> Result<&mut Tree> {
+ self.get_mut(self.selected)
+ }
+
+ fn current_parent(&self) -> Result