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.
- vii-0.1.0a1/.github/workflows/workflow.yml +126 -0
- vii-0.1.0a1/.gitignore +43 -0
- vii-0.1.0a1/.pre-commit-config.yaml +27 -0
- vii-0.1.0a1/PKG-INFO +92 -0
- vii-0.1.0a1/README.md +74 -0
- vii-0.1.0a1/pyproject.toml +58 -0
- vii-0.1.0a1/src/vii/__init__.py +3 -0
- vii-0.1.0a1/src/vii/app.py +227 -0
- vii-0.1.0a1/tests/__init__.py +1 -0
- vii-0.1.0a1/tests/test_app.py +269 -0
|
@@ -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,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
|