cd-browser 0.2.4__tar.gz → 0.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {cd_browser-0.2.4 → cd_browser-0.2.6}/PKG-INFO +68 -8
  2. cd_browser-0.2.6/README.md +200 -0
  3. {cd_browser-0.2.4 → cd_browser-0.2.6}/pyproject.toml +1 -1
  4. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/filesystem.py +39 -5
  5. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/navigator.py +27 -8
  6. cd_browser-0.2.6/src/app/opener.py +189 -0
  7. cd_browser-0.2.6/src/app/ui.py +756 -0
  8. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/cd_browser.egg-info/PKG-INFO +68 -8
  9. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/cd_browser.egg-info/SOURCES.txt +2 -0
  10. {cd_browser-0.2.4 → cd_browser-0.2.6}/tests/test_filesystem.py +29 -1
  11. {cd_browser-0.2.4 → cd_browser-0.2.6}/tests/test_navigator.py +18 -0
  12. cd_browser-0.2.6/tests/test_opener.py +147 -0
  13. {cd_browser-0.2.4 → cd_browser-0.2.6}/tests/test_ui.py +119 -0
  14. cd_browser-0.2.4/README.md +0 -140
  15. cd_browser-0.2.4/src/app/ui.py +0 -344
  16. {cd_browser-0.2.4 → cd_browser-0.2.6}/LICENSE +0 -0
  17. {cd_browser-0.2.4 → cd_browser-0.2.6}/setup.cfg +0 -0
  18. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/__init__.py +0 -0
  19. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/cli.py +0 -0
  20. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/config.py +0 -0
  21. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/domain/__init__.py +0 -0
  22. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/history.py +0 -0
  23. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/infrastructure/__init__.py +0 -0
  24. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/logging_config.py +0 -0
  25. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/main.py +0 -0
  26. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/services/__init__.py +0 -0
  27. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/app/utils/__init__.py +0 -0
  28. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/cd_browser.egg-info/dependency_links.txt +0 -0
  29. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/cd_browser.egg-info/entry_points.txt +0 -0
  30. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/cd_browser.egg-info/requires.txt +0 -0
  31. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/cd_browser.egg-info/top_level.txt +0 -0
  32. {cd_browser-0.2.4 → cd_browser-0.2.6}/src/cd_browser_post_install.py +0 -0
  33. {cd_browser-0.2.4 → cd_browser-0.2.6}/tests/test_cli.py +0 -0
  34. {cd_browser-0.2.4 → cd_browser-0.2.6}/tests/test_history.py +0 -0
  35. {cd_browser-0.2.4 → cd_browser-0.2.6}/tests/test_post_install.py +0 -0
  36. {cd_browser-0.2.4 → cd_browser-0.2.6}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cd-browser
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: A fast keyboard-driven directory navigator for the terminal.
5
5
  Author: Saky
6
6
  Classifier: Development Status :: 3 - Alpha
@@ -34,6 +34,8 @@ It lets you explore directory trees visually and jump to any folder instantly.
34
34
 
35
35
  ![cd-browser demo](https://gitea.sakydogalo.es/saky/cd-browser/raw/branch/main/docs/demo.gif)
36
36
 
37
+ Repository (full demo + docs): https://gitea.sakydogalo.es/saky/cd-browser
38
+
37
39
  ## Why cd-browser?
38
40
 
39
41
  Working in the terminal often means:
@@ -46,14 +48,65 @@ Working in the terminal often means:
46
48
 
47
49
  ## Features
48
50
 
49
- - 🚀 Fast visual navigation of directory trees
50
- - ⌨️ Fully keyboard-driven workflow
51
- - 🌲 Expand and collapse directories
52
- - 📜 Navigation history inside the session
51
+ - ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
52
+ - 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
53
+ - 📜 Navigation history inside the session (`h` history mode, `b` back, `f` forward)
54
+ - 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
55
+ - 📄 Toggle file visibility (`a`) to switch between directories-only and mixed view
56
+ - ⚡ Fast jumps in large lists (`PgUp`/`PgDn`, `Home`/`End`, `g`/`G` in normal mode)
57
+ - 🚀 Contextual `open with` menu (`o`) with app detection based on your environment
58
+ - 🔁 Blocking terminal editors restore the UI state when they exit (`nvim`, `nano`, `less`, `bat`)
59
+ - 👀 Toggle hidden entries with `.`
53
60
  - 🖥 Native terminal interface
54
61
  - 🔁 Works with Bash, Zsh and other shells
55
- - ⚡ Returns the selected path to the shell
56
- - 🔎 Press `.` to toggle hidden directories
62
+ - ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel)
63
+
64
+ ## Controls
65
+
66
+ Normal mode:
67
+
68
+ - `↑` / `↓`: Move selection
69
+ - `→`: Expand directory, or enter when already expanded
70
+ - `←`: Collapse directory, or go to parent
71
+ - `Enter`: Confirm selected path and exit
72
+ - `Esc`: Cancel and return the original path
73
+ - `h`: Toggle history mode
74
+ - `b` / `f`: Back / forward in navigation history
75
+ - `.`: Toggle hidden entries
76
+ - `a`: Toggle file visibility
77
+ - `/`: Enter filter mode
78
+ - `o`: Open selected entry with an app
79
+ - `PgUp` / `PgDn`: Page jump
80
+ - `Home` / `End`: Jump to first/last entry
81
+ - `g` / `G`: Jump to first/last entry
82
+
83
+ Filter mode:
84
+
85
+ - Type text: Filter visible entries in real time (case-insensitive)
86
+ - `↑` / `↓`: Move inside filtered results
87
+ - `Enter`: Apply filter and perform contextual action:
88
+ - Directory: navigate into it without exiting `cd_`
89
+ - File: open app selection menu
90
+ - `Esc`: Exit filter mode and clear query
91
+ - `Backspace`: Remove last filter character
92
+
93
+ History mode:
94
+
95
+ - `↑` / `↓`: Move through history entries
96
+ - `Enter`: Jump to selected history path
97
+ - `Esc` or `h`: Exit history mode
98
+ - `PgUp` / `PgDn`, `Home` / `End`: Fast history navigation
99
+
100
+ Open with menu (`o`):
101
+
102
+ - `↑` / `↓`: Move between app options
103
+ - `Enter`: Launch selected app
104
+ - `Esc`: Cancel
105
+
106
+ The menu only shows apps available in your system (`PATH`) and context:
107
+
108
+ - GUI options (when GUI is available): `code`, system `open`/`xdg-open`
109
+ - Terminal options: `antigravity`, `nvim`, `nano`, `less`, `bat` (as applicable)
57
110
 
58
111
  ## Quick Demo
59
112
 
@@ -124,7 +177,14 @@ Then use:
124
177
  cd_
125
178
  ```
126
179
 
127
- - Dentro de `cd_browser`, presiona `.` para alternar la visualización de carpetas ocultas.
180
+ - Inside `cd_browser`, press `.` to toggle hidden entries.
181
+
182
+ ## Platform Compatibility
183
+
184
+ Practical conclusion:
185
+
186
+ - Best current support: **macOS and Linux**
187
+ - **Windows is partially supported**, but still needs real-world validation in Windows terminal environments before being considered fully supported
128
188
 
129
189
  ## Uninstall
130
190
 
@@ -0,0 +1,200 @@
1
+ # cd-browser
2
+
3
+ **Stop typing paths. Browse them.**
4
+
5
+ `cd-browser` is a fast keyboard-driven directory navigator for the terminal.
6
+ It lets you explore directory trees visually and jump to any folder instantly.
7
+
8
+ ![cd-browser demo](https://gitea.sakydogalo.es/saky/cd-browser/raw/branch/main/docs/demo.gif)
9
+
10
+ Repository (full demo + docs): https://gitea.sakydogalo.es/saky/cd-browser
11
+
12
+ ## Why cd-browser?
13
+
14
+ Working in the terminal often means:
15
+
16
+ - typing long directory paths
17
+ - navigating deep folder trees
18
+ - repeating `cd ..` multiple times
19
+
20
+ `cd-browser` provides an **interactive terminal UI** that allows you to browse directories with the keyboard and return the selected path directly to your shell.
21
+
22
+ ## Features
23
+
24
+ - ⌨️ Fully keyboard-driven workflow (`↑` / `↓` / `←` / `→`)
25
+ - 🌲 Expand and collapse directories (`→` to expand/enter, `←` to collapse/back)
26
+ - 📜 Navigation history inside the session (`h` history mode, `b` back, `f` forward)
27
+ - 🔎 Incremental filter mode (`/`, type to filter, `Backspace`, `Esc`)
28
+ - 📄 Toggle file visibility (`a`) to switch between directories-only and mixed view
29
+ - ⚡ Fast jumps in large lists (`PgUp`/`PgDn`, `Home`/`End`, `g`/`G` in normal mode)
30
+ - 🚀 Contextual `open with` menu (`o`) with app detection based on your environment
31
+ - 🔁 Blocking terminal editors restore the UI state when they exit (`nvim`, `nano`, `less`, `bat`)
32
+ - 👀 Toggle hidden entries with `.`
33
+ - 🖥 Native terminal interface
34
+ - 🔁 Works with Bash, Zsh and other shells
35
+ - ⚡ Returns the selected path to the shell (`Enter` to confirm, `Esc` to cancel)
36
+
37
+ ## Controls
38
+
39
+ Normal mode:
40
+
41
+ - `↑` / `↓`: Move selection
42
+ - `→`: Expand directory, or enter when already expanded
43
+ - `←`: Collapse directory, or go to parent
44
+ - `Enter`: Confirm selected path and exit
45
+ - `Esc`: Cancel and return the original path
46
+ - `h`: Toggle history mode
47
+ - `b` / `f`: Back / forward in navigation history
48
+ - `.`: Toggle hidden entries
49
+ - `a`: Toggle file visibility
50
+ - `/`: Enter filter mode
51
+ - `o`: Open selected entry with an app
52
+ - `PgUp` / `PgDn`: Page jump
53
+ - `Home` / `End`: Jump to first/last entry
54
+ - `g` / `G`: Jump to first/last entry
55
+
56
+ Filter mode:
57
+
58
+ - Type text: Filter visible entries in real time (case-insensitive)
59
+ - `↑` / `↓`: Move inside filtered results
60
+ - `Enter`: Apply filter and perform contextual action:
61
+ - Directory: navigate into it without exiting `cd_`
62
+ - File: open app selection menu
63
+ - `Esc`: Exit filter mode and clear query
64
+ - `Backspace`: Remove last filter character
65
+
66
+ History mode:
67
+
68
+ - `↑` / `↓`: Move through history entries
69
+ - `Enter`: Jump to selected history path
70
+ - `Esc` or `h`: Exit history mode
71
+ - `PgUp` / `PgDn`, `Home` / `End`: Fast history navigation
72
+
73
+ Open with menu (`o`):
74
+
75
+ - `↑` / `↓`: Move between app options
76
+ - `Enter`: Launch selected app
77
+ - `Esc`: Cancel
78
+
79
+ The menu only shows apps available in your system (`PATH`) and context:
80
+
81
+ - GUI options (when GUI is available): `code`, system `open`/`xdg-open`
82
+ - Terminal options: `antigravity`, `nvim`, `nano`, `less`, `bat` (as applicable)
83
+
84
+ ## Quick Demo
85
+
86
+ Run:
87
+
88
+ ```bash
89
+ cd_
90
+ ```
91
+
92
+ Browse directories using the arrow keys and press **Enter** to jump directly to the selected folder.
93
+
94
+ ## Documentation
95
+
96
+ See the documentation index:
97
+
98
+ ```
99
+ docs/index.md
100
+ ```
101
+
102
+ ## Installation
103
+
104
+ Install the project in user mode:
105
+
106
+ ```bash
107
+ pip install cd-browser
108
+ ```
109
+
110
+ **Important**: After installation, run this to set up the `cd_` command:
111
+
112
+ ```bash
113
+ cd_browser_post_install
114
+ ```
115
+
116
+ This interactive script will guide you through enabling `cd_` in your shell.
117
+
118
+ ## Shell Integration For `cd_`
119
+
120
+ Because a Python subprocess cannot directly change the parent shell directory, use a shell wrapper that captures the final path printed by `cd_browser`.
121
+
122
+ Bash or Zsh example:
123
+
124
+ ```bash
125
+ cd_() {
126
+ local target
127
+ target="$(cd_browser)" || return
128
+
129
+ if [ -n "$target" ] && [ -d "$target" ]; then
130
+ cd "$target"
131
+ fi
132
+ }
133
+ ```
134
+
135
+ After adding the function to your shell profile, reload it:
136
+
137
+ ```bash
138
+ source ~/.bashrc
139
+ ```
140
+
141
+ Or:
142
+
143
+ ```bash
144
+ source ~/.zshrc
145
+ ```
146
+
147
+ Then use:
148
+
149
+ ```bash
150
+ cd_
151
+ ```
152
+
153
+ - Inside `cd_browser`, press `.` to toggle hidden entries.
154
+
155
+ ## Platform Compatibility
156
+
157
+ Practical conclusion:
158
+
159
+ - Best current support: **macOS and Linux**
160
+ - **Windows is partially supported**, but still needs real-world validation in Windows terminal environments before being considered fully supported
161
+
162
+ ## Uninstall
163
+
164
+ If the project was installed with `pip`, remove it with:
165
+
166
+ ```bash
167
+ pip uninstall cd-browser
168
+ ```
169
+
170
+ **Important**: After uninstalling, remove the `cd_()` function from your shell profile (~/.bashrc or ~/.zshrc) to clean up completely.
171
+
172
+ ## Developer Setup
173
+
174
+ Recommended setup:
175
+
176
+ ```bash
177
+ make dev
178
+ ```
179
+
180
+ Run the application in development mode:
181
+
182
+ ```bash
183
+ python -m app.main
184
+ ```
185
+
186
+ Useful development commands:
187
+
188
+ ```bash
189
+ make fix
190
+ make quality
191
+ make run
192
+ ```
193
+
194
+ ## Template Origin
195
+
196
+ This project was created from the Python AI Dev Template.
197
+
198
+ The original template documentation has been preserved in `README_TEMPLATE.md` so the project-specific README can focus on `cd-browser` usage and development.
199
+
200
+ For the original template setup, conventions, and generic workflow notes, see `README_TEMPLATE.md`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cd-browser"
7
- version = "0.2.4"
7
+ version = "0.2.6"
8
8
  description = "A fast keyboard-driven directory navigator for the terminal."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -13,11 +13,37 @@ class DirectoryEntry:
13
13
  has_children: bool
14
14
 
15
15
 
16
+ @dataclass(frozen=True, slots=True)
17
+ class FileSystemEntry:
18
+ """Filesystem entry metadata for tree rendering."""
19
+
20
+ name: str
21
+ path: Path
22
+ is_directory: bool
23
+ has_children: bool
24
+
25
+
16
26
  def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEntry]:
17
27
  """Return direct child directories sorted by name."""
18
28
 
29
+ entries = list_entries(path, show_hidden=show_hidden, include_files=False)
30
+ return [
31
+ DirectoryEntry(
32
+ name=entry.name,
33
+ path=entry.path,
34
+ has_children=entry.has_children,
35
+ )
36
+ for entry in entries
37
+ ]
38
+
39
+
40
+ def list_entries(
41
+ path: Path, show_hidden: bool = False, include_files: bool = False
42
+ ) -> list[FileSystemEntry]:
43
+ """Return direct child entries sorted by type and name."""
44
+
19
45
  directory_path = path.expanduser().resolve()
20
- entries: list[DirectoryEntry] = []
46
+ entries: list[FileSystemEntry] = []
21
47
 
22
48
  try:
23
49
  children = list(directory_path.iterdir())
@@ -30,20 +56,28 @@ def list_directories(path: Path, show_hidden: bool = False) -> list[DirectoryEnt
30
56
  except OSError:
31
57
  continue
32
58
 
33
- if not is_directory:
59
+ if not is_directory and not include_files:
34
60
  continue
35
61
  if not show_hidden and child.name.startswith("."):
36
62
  continue
37
63
 
38
64
  entries.append(
39
- DirectoryEntry(
65
+ FileSystemEntry(
40
66
  name=child.name,
41
67
  path=child,
42
- has_children=has_subdirectories(child, show_hidden=show_hidden),
68
+ is_directory=is_directory,
69
+ has_children=(
70
+ has_subdirectories(child, show_hidden=show_hidden)
71
+ if is_directory
72
+ else False
73
+ ),
43
74
  )
44
75
  )
45
76
 
46
- return sorted(entries, key=lambda entry: entry.name.casefold())
77
+ return sorted(
78
+ entries,
79
+ key=lambda entry: (not entry.is_directory, entry.name.casefold()),
80
+ )
47
81
 
48
82
 
49
83
  def has_subdirectories(path: Path, show_hidden: bool = False) -> bool:
@@ -3,13 +3,13 @@ from __future__ import annotations
3
3
  from dataclasses import dataclass, field
4
4
  from pathlib import Path
5
5
 
6
- from app.filesystem import list_directories
6
+ from app.filesystem import list_entries
7
7
  from app.history import SessionHistory
8
8
 
9
9
 
10
10
  @dataclass(frozen=True, slots=True)
11
11
  class VisibleEntry:
12
- """A directory row visible in the navigator."""
12
+ """A row visible in the navigator tree."""
13
13
 
14
14
  name: str
15
15
  path: Path
@@ -17,6 +17,7 @@ class VisibleEntry:
17
17
  has_children: bool
18
18
  is_expanded: bool
19
19
  is_parent: bool = False
20
+ is_file: bool = False
20
21
 
21
22
 
22
23
  @dataclass(slots=True)
@@ -29,6 +30,7 @@ class Navigator:
29
30
  selected_index: int = field(init=False, default=0)
30
31
  _expanded_paths: set[Path] = field(init=False, default_factory=set)
31
32
  show_hidden: bool = False
33
+ show_files: bool = False
32
34
 
33
35
  def __post_init__(self) -> None:
34
36
  self.current_path = self.start_path.expanduser().resolve()
@@ -69,6 +71,9 @@ class Navigator:
69
71
  def enter_selected_directory(self) -> Path:
70
72
  """Enter the currently selected directory and record it in history."""
71
73
 
74
+ if self.selected_entry.is_file:
75
+ return self.current_path
76
+
72
77
  return self._change_directory(self.selected_entry.path)
73
78
 
74
79
  def go_to_parent_directory(self) -> Path:
@@ -149,6 +154,15 @@ class Navigator:
149
154
  """Toggle showing/hiding hidden directory entries."""
150
155
 
151
156
  self.show_hidden = not self.show_hidden
157
+ self._reset_view_state()
158
+
159
+ def toggle_files(self) -> None:
160
+ """Toggle showing/hiding file entries."""
161
+
162
+ self.show_files = not self.show_files
163
+ self._reset_view_state()
164
+
165
+ def _reset_view_state(self) -> None:
152
166
  self.selected_index = 0
153
167
  self._expanded_paths.clear()
154
168
 
@@ -162,20 +176,25 @@ class Navigator:
162
176
  def _build_entries(self, path: Path, depth: int) -> list[VisibleEntry]:
163
177
  entries: list[VisibleEntry] = []
164
178
 
165
- for directory in list_directories(path, show_hidden=self.show_hidden):
166
- is_expanded = directory.path in self._expanded_paths
179
+ for item in list_entries(
180
+ path, show_hidden=self.show_hidden, include_files=self.show_files
181
+ ):
182
+ is_expanded = (
183
+ item.path in self._expanded_paths if item.is_directory else False
184
+ )
167
185
  entries.append(
168
186
  VisibleEntry(
169
- name=directory.name,
170
- path=directory.path,
187
+ name=item.name,
188
+ path=item.path,
171
189
  depth=depth,
172
- has_children=directory.has_children,
190
+ has_children=item.has_children,
173
191
  is_expanded=is_expanded,
192
+ is_file=not item.is_directory,
174
193
  )
175
194
  )
176
195
 
177
196
  if is_expanded:
178
- entries.extend(self._build_entries(directory.path, depth=depth + 1))
197
+ entries.extend(self._build_entries(item.path, depth=depth + 1))
179
198
 
180
199
  return entries
181
200
 
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shlex
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ LaunchMode = Literal["background", "blocking"]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class OpenWithOption:
17
+ """Candidate application command for opening a path."""
18
+
19
+ label: str
20
+ command_template: str
21
+ executable: str
22
+ launch_mode: LaunchMode
23
+ requires_gui: bool = False
24
+ supports_files: bool = True
25
+ supports_directories: bool = True
26
+
27
+
28
+ def available_openers(path: Path) -> list[OpenWithOption]:
29
+ """Return available open-with options for a file or directory path."""
30
+
31
+ is_directory = path.is_dir()
32
+ has_gui = _has_gui_session()
33
+ options: list[OpenWithOption] = []
34
+
35
+ for option in _default_options():
36
+ if is_directory and not option.supports_directories:
37
+ continue
38
+ if not is_directory and not option.supports_files:
39
+ continue
40
+ if option.requires_gui and not has_gui:
41
+ continue
42
+ if not shutil.which(option.executable):
43
+ continue
44
+
45
+ options.append(option)
46
+
47
+ return options
48
+
49
+
50
+ def launch_background_option(path: Path, option: OpenWithOption) -> tuple[bool, str]:
51
+ """Launch a non-blocking option and return status/message."""
52
+
53
+ command = _command_parts(option.command_template, path)
54
+ if not command:
55
+ return False, "Invalid open command"
56
+
57
+ try:
58
+ subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
59
+ except OSError as error:
60
+ return False, f"Open failed: {error}"
61
+
62
+ return True, f"Opened with {option.label}"
63
+
64
+
65
+ def launch_blocking_option(path: Path, option: OpenWithOption) -> tuple[bool, str]:
66
+ """Launch a blocking terminal option and return status/message."""
67
+
68
+ command = _command_parts(option.command_template, path)
69
+ if not command:
70
+ return False, "Invalid open command"
71
+
72
+ try:
73
+ result = subprocess.run(command, check=False)
74
+ except OSError as error:
75
+ return False, f"Open failed: {error}"
76
+
77
+ if result.returncode != 0:
78
+ return (
79
+ False,
80
+ f"Open failed: {option.label} exited with code {result.returncode}",
81
+ )
82
+
83
+ return True, f"Opened with {option.label}"
84
+
85
+
86
+ def is_blocking_option(option: OpenWithOption) -> bool:
87
+ """Return whether the option should block the current terminal session."""
88
+
89
+ return option.launch_mode == "blocking"
90
+
91
+
92
+ def _default_options() -> tuple[OpenWithOption, ...]:
93
+ return (
94
+ OpenWithOption(
95
+ label="VS Code",
96
+ command_template="code {path}",
97
+ executable="code",
98
+ launch_mode="background",
99
+ requires_gui=True,
100
+ ),
101
+ OpenWithOption(
102
+ label="Open (system)",
103
+ command_template=_system_open_template(),
104
+ executable=_system_open_executable(),
105
+ launch_mode="background",
106
+ requires_gui=True,
107
+ supports_directories=False,
108
+ ),
109
+ OpenWithOption(
110
+ label="Antigravity",
111
+ command_template="antigravity {path}",
112
+ executable="antigravity",
113
+ launch_mode="background",
114
+ ),
115
+ OpenWithOption(
116
+ label="Neovim",
117
+ command_template="nvim {path}",
118
+ executable="nvim",
119
+ launch_mode="blocking",
120
+ ),
121
+ OpenWithOption(
122
+ label="Nano",
123
+ command_template="nano {path}",
124
+ executable="nano",
125
+ launch_mode="blocking",
126
+ supports_directories=False,
127
+ ),
128
+ OpenWithOption(
129
+ label="Less",
130
+ command_template="less {path}",
131
+ executable="less",
132
+ launch_mode="blocking",
133
+ supports_directories=False,
134
+ ),
135
+ OpenWithOption(
136
+ label="Bat",
137
+ command_template="bat {path}",
138
+ executable="bat",
139
+ launch_mode="blocking",
140
+ supports_directories=False,
141
+ ),
142
+ )
143
+
144
+
145
+ def _has_gui_session() -> bool:
146
+ if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"):
147
+ return False
148
+
149
+ if sys.platform == "darwin":
150
+ return True
151
+
152
+ if sys.platform.startswith("linux"):
153
+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
154
+
155
+ if sys.platform.startswith("win"):
156
+ return True
157
+
158
+ return False
159
+
160
+
161
+ def _system_open_template() -> str:
162
+ if sys.platform == "darwin":
163
+ return "open {path}"
164
+
165
+ if sys.platform.startswith("linux"):
166
+ return "xdg-open {path}"
167
+
168
+ if sys.platform.startswith("win"):
169
+ return 'cmd /c start "" {path}'
170
+
171
+ return "open {path}"
172
+
173
+
174
+ def _system_open_executable() -> str:
175
+ if sys.platform == "darwin":
176
+ return "open"
177
+
178
+ if sys.platform.startswith("linux"):
179
+ return "xdg-open"
180
+
181
+ if sys.platform.startswith("win"):
182
+ return "cmd"
183
+
184
+ return "open"
185
+
186
+
187
+ def _command_parts(command_template: str, path: Path) -> list[str]:
188
+ rendered = command_template.format(path=shlex.quote(str(path)))
189
+ return shlex.split(rendered)