vii 0.1.0a1__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.
@@ -0,0 +1,126 @@
1
+ name: CI/CD
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ release:
9
+ types: [published]
10
+ workflow_dispatch:
11
+ inputs:
12
+ publish_to:
13
+ description: 'Where to publish the package'
14
+ required: true
15
+ type: choice
16
+ options:
17
+ - 'none'
18
+ - 'testpypi'
19
+ - 'pypi'
20
+ default: 'none'
21
+
22
+ permissions:
23
+ contents: read
24
+
25
+ jobs:
26
+ test:
27
+ name: Test on Python ${{ matrix.python-version }}
28
+ runs-on: ubuntu-latest
29
+
30
+ strategy:
31
+ matrix:
32
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
33
+
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+
37
+ - name: Set up Python ${{ matrix.python-version }}
38
+ uses: actions/setup-python@v5
39
+ with:
40
+ python-version: ${{ matrix.python-version }}
41
+
42
+ - name: Install dependencies
43
+ run: |
44
+ python -m pip install --upgrade pip
45
+ pip install -e ".[dev]"
46
+
47
+ - name: Run pre-commit hooks
48
+ run: pre-commit run --all-files
49
+
50
+ - name: Run tests
51
+ run: pytest tests/ -v --tb=short
52
+
53
+ build:
54
+ name: Build distribution
55
+ runs-on: ubuntu-latest
56
+ if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
57
+
58
+ steps:
59
+ - uses: actions/checkout@v4
60
+
61
+ - name: Set up Python
62
+ uses: actions/setup-python@v5
63
+ with:
64
+ python-version: "3.x"
65
+
66
+ - name: Install build dependencies
67
+ run: |
68
+ python -m pip install --upgrade pip
69
+ pip install build
70
+
71
+ - name: Build package
72
+ run: python -m build
73
+
74
+ - name: Store the distribution packages
75
+ uses: actions/upload-artifact@v4
76
+ with:
77
+ name: python-package-distributions
78
+ path: dist/
79
+
80
+ publish-to-pypi:
81
+ name: Publish to PyPI
82
+ needs: [build]
83
+ runs-on: ubuntu-latest
84
+ if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_to == 'pypi')
85
+
86
+ environment:
87
+ name: pypi
88
+ url: https://pypi.org/p/vii
89
+
90
+ permissions:
91
+ id-token: write # IMPORTANT: mandatory for trusted publishing
92
+
93
+ steps:
94
+ - name: Download all the dists
95
+ uses: actions/download-artifact@v4
96
+ with:
97
+ name: python-package-distributions
98
+ path: dist/
99
+
100
+ - name: Publish distribution to PyPI
101
+ uses: pypa/gh-action-pypi-publish@release/v1
102
+
103
+ publish-to-testpypi:
104
+ name: Publish to TestPyPI
105
+ needs: [build]
106
+ runs-on: ubuntu-latest
107
+ if: github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_to == 'testpypi')
108
+
109
+ environment:
110
+ name: testpypi
111
+ url: https://test.pypi.org/p/vii
112
+
113
+ permissions:
114
+ id-token: write # IMPORTANT: mandatory for trusted publishing
115
+
116
+ steps:
117
+ - name: Download all the dists
118
+ uses: actions/download-artifact@v4
119
+ with:
120
+ name: python-package-distributions
121
+ path: dist/
122
+
123
+ - name: Publish distribution to TestPyPI
124
+ uses: pypa/gh-action-pypi-publish@release/v1
125
+ with:
126
+ repository-url: https://test.pypi.org/legacy/
vii-0.1.0a1/.gitignore ADDED
@@ -0,0 +1,43 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual environments
24
+ venv/
25
+ env/
26
+ ENV/
27
+ .venv
28
+
29
+ # IDE
30
+ .vscode/
31
+ .idea/
32
+ *.swp
33
+ *.swo
34
+ *~
35
+
36
+ # Testing
37
+ .pytest_cache/
38
+ .coverage
39
+ htmlcov/
40
+
41
+ # OS
42
+ .DS_Store
43
+ Thumbs.db
@@ -0,0 +1,27 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v6.0.0
4
+ hooks:
5
+ - id: trailing-whitespace
6
+ - id: end-of-file-fixer
7
+ - id: check-yaml
8
+ - id: check-added-large-files
9
+ - id: check-merge-conflict
10
+ - id: check-toml
11
+ - id: debug-statements
12
+ - id: mixed-line-ending
13
+
14
+ - repo: https://github.com/astral-sh/ruff-pre-commit
15
+ rev: v0.15.5
16
+ hooks:
17
+ # Run the linter
18
+ - id: ruff
19
+ args: [--fix]
20
+ # Run the formatter
21
+ - id: ruff-format
22
+
23
+ - repo: https://github.com/pre-commit/mirrors-mypy
24
+ rev: v1.19.1
25
+ hooks:
26
+ - id: mypy
27
+ args: ["--ignore-missing-imports", "--no-strict-optional"]
vii-0.1.0a1/PKG-INFO ADDED
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: vii
3
+ Version: 0.1.0a1
4
+ Summary: vii - A terminal-based file browser that opens files in your editor
5
+ Author: Alex Clark
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: textual>=0.50.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
12
+ Requires-Dist: pre-commit>=3.0.0; extra == 'dev'
13
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
14
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
15
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
16
+ Requires-Dist: textual-dev>=1.0.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # vii
20
+
21
+ A terminal-based file browser that opens selected files in your preferred editor.
22
+
23
+ ## Features
24
+
25
+ - 🗂️ Interactive file browser using Textual's DirectoryTree
26
+ - 🚀 Opens files in your preferred editor (VS Code, Sublime, Vim, etc.)
27
+ - ⌨️ Keyboard-driven interface
28
+ - 🎨 Clean, terminal-based UI
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install -e .
34
+ ```
35
+
36
+ For development:
37
+
38
+ ```bash
39
+ pip install -e ".[dev]"
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ Run vii from any directory:
45
+
46
+ ```bash
47
+ vii
48
+ ```
49
+
50
+ Or specify a directory to browse:
51
+
52
+ ```bash
53
+ vii /path/to/project
54
+ ```
55
+
56
+ ## Keyboard Shortcuts
57
+
58
+ Vi-style navigation (arrow keys also work):
59
+
60
+ - `j/k` - Navigate down/up
61
+ - `h/l` - Collapse/expand directories
62
+ - `g` - Jump to top
63
+ - `G` - Jump to bottom
64
+ - `Enter` - Open selected file in editor
65
+ - `q` - Quit
66
+ - `Ctrl+C` - Quit
67
+
68
+ ## Editor Detection
69
+
70
+ vii automatically detects your preferred editor by checking:
71
+ 1. `$VISUAL` environment variable
72
+ 2. `$EDITOR` environment variable
73
+ 3. Common editors: `code`, `subl`, `atom`, `vim`, `nvim`, `nano`
74
+ 4. Falls back to `open` (macOS default)
75
+
76
+ ### Editor Behavior
77
+
78
+ - **GUI Editors** (VS Code, Sublime, etc.): Opens in the background while vii continues running
79
+ - **Terminal Editors** (vim, nvim, nano, etc.): vii suspends and the editor takes over full screen. When you quit the editor, vii resumes automatically
80
+
81
+ ## Development
82
+
83
+ Run with Textual's development console:
84
+
85
+ ```bash
86
+ textual console
87
+ textual run --dev src/vii/app.py
88
+ ```
89
+
90
+ ## License
91
+
92
+ MIT
vii-0.1.0a1/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # vii
2
+
3
+ A terminal-based file browser that opens selected files in your preferred editor.
4
+
5
+ ## Features
6
+
7
+ - 🗂️ Interactive file browser using Textual's DirectoryTree
8
+ - 🚀 Opens files in your preferred editor (VS Code, Sublime, Vim, etc.)
9
+ - ⌨️ Keyboard-driven interface
10
+ - 🎨 Clean, terminal-based UI
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install -e .
16
+ ```
17
+
18
+ For development:
19
+
20
+ ```bash
21
+ pip install -e ".[dev]"
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ Run vii from any directory:
27
+
28
+ ```bash
29
+ vii
30
+ ```
31
+
32
+ Or specify a directory to browse:
33
+
34
+ ```bash
35
+ vii /path/to/project
36
+ ```
37
+
38
+ ## Keyboard Shortcuts
39
+
40
+ Vi-style navigation (arrow keys also work):
41
+
42
+ - `j/k` - Navigate down/up
43
+ - `h/l` - Collapse/expand directories
44
+ - `g` - Jump to top
45
+ - `G` - Jump to bottom
46
+ - `Enter` - Open selected file in editor
47
+ - `q` - Quit
48
+ - `Ctrl+C` - Quit
49
+
50
+ ## Editor Detection
51
+
52
+ vii automatically detects your preferred editor by checking:
53
+ 1. `$VISUAL` environment variable
54
+ 2. `$EDITOR` environment variable
55
+ 3. Common editors: `code`, `subl`, `atom`, `vim`, `nvim`, `nano`
56
+ 4. Falls back to `open` (macOS default)
57
+
58
+ ### Editor Behavior
59
+
60
+ - **GUI Editors** (VS Code, Sublime, etc.): Opens in the background while vii continues running
61
+ - **Terminal Editors** (vim, nvim, nano, etc.): vii suspends and the editor takes over full screen. When you quit the editor, vii resumes automatically
62
+
63
+ ## Development
64
+
65
+ Run with Textual's development console:
66
+
67
+ ```bash
68
+ textual console
69
+ textual run --dev src/vii/app.py
70
+ ```
71
+
72
+ ## License
73
+
74
+ MIT
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vii"
7
+ version = "0.1.0a1"
8
+ description = "vii - A terminal-based file browser that opens files in your editor"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Alex Clark"},
14
+ ]
15
+ dependencies = [
16
+ "textual>=0.50.0",
17
+ "rich>=13.0.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "pytest>=7.0.0",
23
+ "pytest-asyncio>=0.21.0",
24
+ "textual-dev>=1.0.0",
25
+ "pre-commit>=3.0.0",
26
+ "ruff>=0.8.0",
27
+ "mypy>=1.0.0",
28
+ ]
29
+
30
+ [project.scripts]
31
+ vii = "vii.app:main"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/vii"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ asyncio_mode = "auto"
39
+
40
+ [tool.ruff]
41
+ line-length = 100
42
+ target-version = "py310"
43
+
44
+ [tool.ruff.lint]
45
+ select = [
46
+ "E", # pycodestyle errors
47
+ "W", # pycodestyle warnings
48
+ "F", # pyflakes
49
+ "I", # isort
50
+ "B", # flake8-bugbear
51
+ "C4", # flake8-comprehensions
52
+ "UP", # pyupgrade
53
+ ]
54
+ ignore = []
55
+
56
+ [tool.ruff.format]
57
+ quote-style = "double"
58
+ indent-style = "space"
@@ -0,0 +1,3 @@
1
+ """vii - A terminal-based file browser."""
2
+
3
+ __version__ = "0.1.0a1"
@@ -0,0 +1,227 @@
1
+ """Main application entry point for vii."""
2
+
3
+ import os
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from textual import events
9
+ from textual.app import App, ComposeResult
10
+ from textual.binding import Binding
11
+ from textual.containers import Horizontal, Vertical
12
+ from textual.widgets import DirectoryTree, Footer, Header, Static
13
+
14
+
15
+ class Vii(App):
16
+ """vii - Terminal file browser."""
17
+
18
+ TITLE = "vii"
19
+
20
+ CSS = """
21
+ Screen {
22
+ layout: grid;
23
+ grid-size: 2 1;
24
+ grid-columns: 1fr 2fr;
25
+ }
26
+
27
+ #sidebar {
28
+ width: 100%;
29
+ height: 100%;
30
+ border-right: solid $primary;
31
+ }
32
+
33
+ #main-content {
34
+ width: 100%;
35
+ height: 100%;
36
+ padding: 1 2;
37
+ }
38
+
39
+ DirectoryTree {
40
+ width: 100%;
41
+ height: 100%;
42
+ }
43
+
44
+ .info-text {
45
+ color: $text-muted;
46
+ text-style: italic;
47
+ }
48
+ """
49
+
50
+ BINDINGS = [
51
+ Binding("q", "quit", "Quit", priority=True),
52
+ Binding("ctrl+c", "quit", "Quit", show=False),
53
+ # Vi-style navigation (shown in footer)
54
+ Binding("j", "cursor_down", "Down"),
55
+ Binding("k", "cursor_up", "Up"),
56
+ Binding("h", "cursor_left", "Collapse"),
57
+ Binding("l", "cursor_right", "Expand"),
58
+ Binding("g", "scroll_home", "Top"),
59
+ Binding("G", "scroll_end", "Bottom"),
60
+ # Arrow keys still work but hidden from footer
61
+ Binding("down", "cursor_down", "Down", show=False),
62
+ Binding("up", "cursor_up", "Up", show=False),
63
+ Binding("left", "cursor_left", "Left", show=False),
64
+ Binding("right", "cursor_right", "Right", show=False),
65
+ ]
66
+
67
+ def __init__(self, start_path: Path | None = None):
68
+ super().__init__()
69
+ self.start_path = start_path or Path.cwd()
70
+ self.editor_command = self._detect_editor()
71
+ self.is_terminal_editor = self._is_terminal_editor()
72
+
73
+ def _detect_editor(self) -> list[str]:
74
+ """Detect the user's preferred editor."""
75
+ # Check common environment variables
76
+ editor = None
77
+ for env_var in ["VISUAL", "EDITOR"]:
78
+ editor = os.environ.get(env_var)
79
+ if editor:
80
+ break
81
+
82
+ if not editor:
83
+ # Try common GUI editors first, then terminal editors
84
+ for cmd in ["code", "subl", "atom", "vim", "nvim", "nano"]:
85
+ try:
86
+ subprocess.run(
87
+ ["which", cmd],
88
+ capture_output=True,
89
+ check=True,
90
+ )
91
+ editor = cmd
92
+ break
93
+ except subprocess.CalledProcessError:
94
+ continue
95
+
96
+ return [editor] if editor else ["open"]
97
+
98
+ def _is_terminal_editor(self) -> bool:
99
+ """Check if the detected editor is a terminal-based editor."""
100
+ if not self.editor_command:
101
+ return False
102
+
103
+ terminal_editors = {
104
+ "vim",
105
+ "nvim",
106
+ "vi",
107
+ "nano",
108
+ "emacs",
109
+ "micro",
110
+ "helix",
111
+ "hx",
112
+ "joe",
113
+ "ne",
114
+ "ed",
115
+ "ex",
116
+ }
117
+
118
+ editor_name = Path(self.editor_command[0]).name
119
+ return editor_name in terminal_editors
120
+
121
+ def compose(self) -> ComposeResult:
122
+ """Compose the UI."""
123
+ yield Header()
124
+
125
+ with Horizontal():
126
+ with Vertical(id="sidebar"):
127
+ yield DirectoryTree(str(self.start_path))
128
+
129
+ with Vertical(id="main-content"):
130
+ editor_type = "terminal" if self.is_terminal_editor else "GUI"
131
+ yield Static(
132
+ "Select a file from the tree to open it in your editor.\n\n"
133
+ f"Editor: {' '.join(self.editor_command)} ({editor_type})",
134
+ classes="info-text",
135
+ )
136
+
137
+ yield Footer()
138
+
139
+ def on_key(self, event: events.Key) -> None:
140
+ """Handle key presses for vi-style navigation."""
141
+ tree = self.query_one(DirectoryTree)
142
+
143
+ # Map vi keys to actions
144
+ key_map = {
145
+ "j": "down",
146
+ "k": "up",
147
+ "h": "left",
148
+ "l": "right",
149
+ "g": "home",
150
+ "G": "end",
151
+ }
152
+
153
+ if event.key in key_map:
154
+ # Prevent the key from being processed further
155
+ event.prevent_default()
156
+ # Simulate the corresponding arrow key or action
157
+ action_key = key_map[event.key]
158
+ if action_key == "down":
159
+ tree.action_cursor_down()
160
+ elif action_key == "up":
161
+ tree.action_cursor_up()
162
+ elif action_key == "left":
163
+ tree.action_cursor_left()
164
+ elif action_key == "right":
165
+ tree.action_cursor_right()
166
+ elif action_key == "home":
167
+ tree.action_scroll_home()
168
+ elif action_key == "end":
169
+ tree.action_scroll_end()
170
+
171
+ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
172
+ """Handle file selection from the directory tree."""
173
+ file_path = event.path
174
+ self._open_in_editor(file_path)
175
+
176
+ def _open_in_editor(self, file_path: Path) -> None:
177
+ """Open a file in the user's editor."""
178
+ if self.is_terminal_editor:
179
+ self._open_in_terminal_editor(file_path)
180
+ else:
181
+ self._open_in_gui_editor(file_path)
182
+
183
+ def _open_in_terminal_editor(self, file_path: Path) -> None:
184
+ """Open a file in a terminal editor by suspending the app."""
185
+ try:
186
+ # Suspend the Textual app to give control back to the terminal
187
+ with self.suspend():
188
+ # Run the editor and wait for it to complete
189
+ result = subprocess.run(
190
+ [*self.editor_command, str(file_path)],
191
+ )
192
+ if result.returncode != 0:
193
+ self.notify(
194
+ f"Editor exited with code {result.returncode}",
195
+ severity="warning",
196
+ )
197
+ except Exception as e:
198
+ self.notify(f"Error opening file: {e}", severity="error")
199
+
200
+ def _open_in_gui_editor(self, file_path: Path) -> None:
201
+ """Open a file in a GUI editor (non-blocking)."""
202
+ try:
203
+ subprocess.Popen(
204
+ [*self.editor_command, str(file_path)],
205
+ stdin=subprocess.DEVNULL,
206
+ stdout=subprocess.DEVNULL,
207
+ stderr=subprocess.DEVNULL,
208
+ )
209
+ self.notify(f"Opened: {file_path.name}", severity="information")
210
+ except Exception as e:
211
+ self.notify(f"Error opening file: {e}", severity="error")
212
+
213
+
214
+ def main():
215
+ """Main entry point."""
216
+ start_path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
217
+
218
+ if not start_path.exists():
219
+ print(f"Error: Path '{start_path}' does not exist", file=sys.stderr)
220
+ sys.exit(1)
221
+
222
+ app = Vii(start_path=start_path)
223
+ app.run()
224
+
225
+
226
+ if __name__ == "__main__":
227
+ main()
@@ -0,0 +1 @@
1
+ """Tests for vii."""
@@ -0,0 +1,269 @@
1
+ """Tests for the main vii application."""
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+ from unittest.mock import Mock, patch
7
+
8
+ import pytest
9
+ from textual.widgets import DirectoryTree
10
+
11
+ from vii.app import Vii, main
12
+
13
+
14
+ class TestVii:
15
+ """Test cases for the Vii class."""
16
+
17
+ def test_init_with_default_path(self):
18
+ """Test initialization with default path."""
19
+ app = Vii()
20
+ assert app.start_path == Path.cwd()
21
+ assert isinstance(app.editor_command, list)
22
+ assert len(app.editor_command) > 0
23
+
24
+ def test_init_with_custom_path(self, tmp_path):
25
+ """Test initialization with custom path."""
26
+ app = Vii(start_path=tmp_path)
27
+ assert app.start_path == tmp_path
28
+
29
+ @patch.dict(os.environ, {"VISUAL": "vim"})
30
+ def test_detect_editor_visual_env(self):
31
+ """Test editor detection using VISUAL environment variable."""
32
+ app = Vii()
33
+ assert app.editor_command == ["vim"]
34
+
35
+ @patch.dict(os.environ, {"EDITOR": "nano"}, clear=True)
36
+ def test_detect_editor_editor_env(self):
37
+ """Test editor detection using EDITOR environment variable."""
38
+ app = Vii()
39
+ assert app.editor_command == ["nano"]
40
+
41
+ @patch.dict(os.environ, {}, clear=True)
42
+ @patch("subprocess.run")
43
+ def test_detect_editor_which_code(self, mock_run):
44
+ """Test editor detection using 'which' command for VS Code."""
45
+ # First call to 'which code' succeeds
46
+ mock_run.return_value = Mock(returncode=0)
47
+ app = Vii()
48
+ assert app.editor_command == ["code"]
49
+
50
+ @patch.dict(os.environ, {}, clear=True)
51
+ @patch("subprocess.run")
52
+ def test_detect_editor_fallback_to_open(self, mock_run):
53
+ """Test editor detection fallback to 'open'."""
54
+ # All 'which' commands fail
55
+ mock_run.side_effect = subprocess.CalledProcessError(1, "which")
56
+ app = Vii()
57
+ assert app.editor_command == ["open"]
58
+
59
+ def test_is_terminal_editor_vim(self):
60
+ """Test detection of vim as terminal editor."""
61
+ app = Vii()
62
+ app.editor_command = ["vim"]
63
+ assert app._is_terminal_editor() is True
64
+
65
+ def test_is_terminal_editor_nvim(self):
66
+ """Test detection of nvim as terminal editor."""
67
+ app = Vii()
68
+ app.editor_command = ["nvim"]
69
+ assert app._is_terminal_editor() is True
70
+
71
+ def test_is_terminal_editor_code(self):
72
+ """Test detection of VS Code as GUI editor."""
73
+ app = Vii()
74
+ app.editor_command = ["code"]
75
+ assert app._is_terminal_editor() is False
76
+
77
+ def test_is_terminal_editor_with_path(self):
78
+ """Test detection works with full paths."""
79
+ app = Vii()
80
+ app.editor_command = ["/usr/bin/vim"]
81
+ assert app._is_terminal_editor() is True
82
+
83
+ def test_open_in_gui_editor_success(self, tmp_path):
84
+ """Test successfully opening a file in GUI editor."""
85
+ test_file = tmp_path / "test.txt"
86
+ test_file.write_text("test content")
87
+
88
+ app = Vii(start_path=tmp_path)
89
+ app.editor_command = ["test-editor"]
90
+ app.is_terminal_editor = False
91
+
92
+ # Mock Popen and notify after app initialization
93
+ with (
94
+ patch("subprocess.Popen") as mock_popen,
95
+ patch.object(app, "notify") as mock_notify,
96
+ ):
97
+ app._open_in_editor(test_file)
98
+
99
+ # Verify Popen was called with correct arguments
100
+ mock_popen.assert_called_once_with(
101
+ ["test-editor", str(test_file)],
102
+ stdin=subprocess.DEVNULL,
103
+ stdout=subprocess.DEVNULL,
104
+ stderr=subprocess.DEVNULL,
105
+ )
106
+
107
+ # Verify notification was sent
108
+ mock_notify.assert_called_once_with(f"Opened: {test_file.name}", severity="information")
109
+
110
+ @patch("subprocess.run")
111
+ def test_open_in_terminal_editor_success(self, mock_run, tmp_path):
112
+ """Test successfully opening a file in terminal editor."""
113
+ test_file = tmp_path / "test.txt"
114
+ test_file.write_text("test content")
115
+
116
+ # Mock successful editor run
117
+ mock_run.return_value = Mock(returncode=0)
118
+
119
+ app = Vii(start_path=tmp_path)
120
+ app.editor_command = ["vim"]
121
+ app.is_terminal_editor = True
122
+
123
+ # Mock the suspend method to avoid actually suspending
124
+ with patch.object(app, "suspend") as mock_suspend:
125
+ mock_suspend.return_value.__enter__ = Mock()
126
+ mock_suspend.return_value.__exit__ = Mock(return_value=False)
127
+
128
+ app._open_in_editor(test_file)
129
+
130
+ # Verify subprocess.run was called with the file
131
+ # Note: mock_run is called during __init__ for editor detection too
132
+ # So we check the last call
133
+ assert mock_run.call_args == ((["vim", str(test_file)],), {})
134
+
135
+ @patch("subprocess.run")
136
+ def test_open_in_terminal_editor_nonzero_exit(self, mock_run, tmp_path):
137
+ """Test terminal editor with non-zero exit code."""
138
+ test_file = tmp_path / "test.txt"
139
+ test_file.write_text("test content")
140
+
141
+ # Mock editor run with non-zero exit
142
+ mock_run.return_value = Mock(returncode=1)
143
+
144
+ app = Vii(start_path=tmp_path)
145
+ app.editor_command = ["vim"]
146
+ app.is_terminal_editor = True
147
+
148
+ with (
149
+ patch.object(app, "suspend") as mock_suspend,
150
+ patch.object(app, "notify") as mock_notify,
151
+ ):
152
+ mock_suspend.return_value.__enter__ = Mock()
153
+ mock_suspend.return_value.__exit__ = Mock(return_value=False)
154
+
155
+ app._open_in_editor(test_file)
156
+
157
+ # Verify warning notification was sent
158
+ mock_notify.assert_called_once()
159
+ call_args = mock_notify.call_args
160
+ assert "exited with code" in call_args[0][0]
161
+ assert call_args[1]["severity"] == "warning"
162
+
163
+ def test_open_in_gui_editor_failure(self, tmp_path):
164
+ """Test handling of GUI editor opening failure."""
165
+ test_file = tmp_path / "test.txt"
166
+ test_file.write_text("test content")
167
+
168
+ app = Vii(start_path=tmp_path)
169
+ app.editor_command = ["nonexistent-editor"]
170
+ app.is_terminal_editor = False
171
+
172
+ # Mock Popen to raise an exception and notify after app initialization
173
+ with (
174
+ patch("subprocess.Popen") as mock_popen,
175
+ patch.object(app, "notify") as mock_notify,
176
+ ):
177
+ # Make Popen raise an exception
178
+ mock_popen.side_effect = OSError("Editor not found")
179
+
180
+ app._open_in_editor(test_file)
181
+
182
+ # Verify error notification was sent
183
+ mock_notify.assert_called_once()
184
+ call_args = mock_notify.call_args
185
+ assert "Error opening file" in call_args[0][0]
186
+ assert call_args[1]["severity"] == "error"
187
+
188
+ async def test_compose(self, tmp_path):
189
+ """Test UI composition."""
190
+ app = Vii(start_path=tmp_path)
191
+ async with app.run_test():
192
+ # Check that the directory tree is present
193
+ tree = app.query_one(DirectoryTree)
194
+ assert tree is not None
195
+
196
+ # Check that the static info text is present
197
+ from textual.widgets import Static
198
+
199
+ statics = app.query(Static)
200
+ # Should have at least one Static widget with our info text
201
+ assert len(statics) > 0
202
+ # Find the one with our text
203
+ found_info_text = False
204
+ for static in statics:
205
+ if hasattr(static, "render") and "Select a file" in str(static.render()):
206
+ found_info_text = True
207
+ break
208
+ assert found_info_text
209
+
210
+ async def test_file_selection(self, tmp_path):
211
+ """Test file selection triggers editor opening."""
212
+ # Create a test file
213
+ test_file = tmp_path / "test.txt"
214
+ test_file.write_text("test content")
215
+
216
+ app = Vii(start_path=tmp_path)
217
+
218
+ with patch.object(app, "_open_in_editor") as mock_open:
219
+ async with app.run_test():
220
+ # Simulate file selection
221
+ tree = app.query_one(DirectoryTree)
222
+ event = DirectoryTree.FileSelected(tree, test_file)
223
+ app.on_directory_tree_file_selected(event)
224
+
225
+ # Verify _open_in_editor was called
226
+ mock_open.assert_called_once_with(test_file)
227
+
228
+
229
+ class TestMain:
230
+ """Test cases for the main entry point."""
231
+
232
+ @patch("vii.app.Vii")
233
+ def test_main_default_path(self, mock_vii_class):
234
+ """Test main function with default path."""
235
+ mock_app = Mock()
236
+ mock_vii_class.return_value = mock_app
237
+
238
+ with patch("sys.argv", ["vii"]):
239
+ main()
240
+
241
+ mock_vii_class.assert_called_once()
242
+ call_args = mock_vii_class.call_args
243
+ assert call_args[1]["start_path"] == Path.cwd()
244
+ mock_app.run.assert_called_once()
245
+
246
+ @patch("vii.app.Vii")
247
+ def test_main_custom_path(self, mock_vii_class, tmp_path):
248
+ """Test main function with custom path."""
249
+ mock_app = Mock()
250
+ mock_vii_class.return_value = mock_app
251
+
252
+ with patch("sys.argv", ["vii", str(tmp_path)]):
253
+ main()
254
+
255
+ mock_vii_class.assert_called_once()
256
+ call_args = mock_vii_class.call_args
257
+ assert call_args[1]["start_path"] == tmp_path
258
+ mock_app.run.assert_called_once()
259
+
260
+ def test_main_nonexistent_path(self, capsys):
261
+ """Test main function with nonexistent path."""
262
+ with patch("sys.argv", ["vii", "/nonexistent/path"]):
263
+ with pytest.raises(SystemExit) as exc_info:
264
+ main()
265
+
266
+ assert exc_info.value.code == 1
267
+
268
+ captured = capsys.readouterr()
269
+ assert "does not exist" in captured.err