claude-launcher 0.1.0__py3-none-any.whl
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.
- claude_launcher/__init__.py +3 -0
- claude_launcher/__main__.py +6 -0
- claude_launcher/cli.py +199 -0
- claude_launcher/core/__init__.py +1 -0
- claude_launcher/core/config.py +250 -0
- claude_launcher/core/discovery.py +97 -0
- claude_launcher/core/models.py +129 -0
- claude_launcher/core/storage.py +247 -0
- claude_launcher/ui/__init__.py +1 -0
- claude_launcher/ui/_preview_helper.py +72 -0
- claude_launcher/ui/browser.py +196 -0
- claude_launcher/ui/preview.py +265 -0
- claude_launcher/ui/selector.py +272 -0
- claude_launcher/utils/__init__.py +1 -0
- claude_launcher/utils/git.py +148 -0
- claude_launcher/utils/logging.py +79 -0
- claude_launcher/utils/paths.py +61 -0
- claude_launcher-0.1.0.dist-info/METADATA +332 -0
- claude_launcher-0.1.0.dist-info/RECORD +23 -0
- claude_launcher-0.1.0.dist-info/WHEEL +5 -0
- claude_launcher-0.1.0.dist-info/entry_points.txt +2 -0
- claude_launcher-0.1.0.dist-info/licenses/LICENSE +21 -0
- claude_launcher-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""SQLite storage for claude-launcher."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from claude_launcher.core.models import Project
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Storage:
|
|
14
|
+
"""Manages SQLite database for manual paths and history."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, db_path: Path):
|
|
17
|
+
"""Initialize storage.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
db_path: Path to SQLite database file
|
|
21
|
+
"""
|
|
22
|
+
self.db_path = db_path
|
|
23
|
+
self._ensure_database()
|
|
24
|
+
|
|
25
|
+
def _ensure_database(self) -> None:
|
|
26
|
+
"""Ensure database exists and has correct schema."""
|
|
27
|
+
try:
|
|
28
|
+
# Try to connect and validate
|
|
29
|
+
conn = sqlite3.connect(self.db_path)
|
|
30
|
+
cursor = conn.cursor()
|
|
31
|
+
|
|
32
|
+
# Check if tables exist
|
|
33
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
|
34
|
+
tables = {row[0] for row in cursor.fetchall()}
|
|
35
|
+
|
|
36
|
+
if "manual_projects" not in tables or "last_opened" not in tables:
|
|
37
|
+
# Create schema
|
|
38
|
+
self._create_schema(conn)
|
|
39
|
+
else:
|
|
40
|
+
# Validate we can query
|
|
41
|
+
cursor.execute("SELECT 1 FROM manual_projects LIMIT 1")
|
|
42
|
+
|
|
43
|
+
conn.close()
|
|
44
|
+
|
|
45
|
+
except sqlite3.DatabaseError as e:
|
|
46
|
+
print(f"Database corruption detected: {e}")
|
|
47
|
+
self._recover_database()
|
|
48
|
+
|
|
49
|
+
def _create_schema(self, conn: sqlite3.Connection) -> None:
|
|
50
|
+
"""Create database schema.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
conn: SQLite connection
|
|
54
|
+
"""
|
|
55
|
+
cursor = conn.cursor()
|
|
56
|
+
|
|
57
|
+
# Manual projects table
|
|
58
|
+
cursor.execute(
|
|
59
|
+
"""
|
|
60
|
+
CREATE TABLE IF NOT EXISTS manual_projects (
|
|
61
|
+
path TEXT PRIMARY KEY,
|
|
62
|
+
added_at TEXT NOT NULL
|
|
63
|
+
)
|
|
64
|
+
"""
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Last opened project table (single row)
|
|
68
|
+
cursor.execute(
|
|
69
|
+
"""
|
|
70
|
+
CREATE TABLE IF NOT EXISTS last_opened (
|
|
71
|
+
path TEXT PRIMARY KEY
|
|
72
|
+
)
|
|
73
|
+
"""
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Access history (optional, for future analytics)
|
|
77
|
+
cursor.execute(
|
|
78
|
+
"""
|
|
79
|
+
CREATE TABLE IF NOT EXISTS access_history (
|
|
80
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
81
|
+
path TEXT NOT NULL,
|
|
82
|
+
accessed_at TEXT NOT NULL
|
|
83
|
+
)
|
|
84
|
+
"""
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
cursor.execute(
|
|
88
|
+
"""
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_access_history_path
|
|
90
|
+
ON access_history(path)
|
|
91
|
+
"""
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
cursor.execute(
|
|
95
|
+
"""
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_access_history_time
|
|
97
|
+
ON access_history(accessed_at)
|
|
98
|
+
"""
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
conn.commit()
|
|
102
|
+
|
|
103
|
+
def _recover_database(self) -> None:
|
|
104
|
+
"""Recover from corrupted database by backing up and recreating."""
|
|
105
|
+
if self.db_path.exists():
|
|
106
|
+
# Backup corrupted database
|
|
107
|
+
backup_path = self.db_path.with_suffix(f".db.backup.{int(time.time())}")
|
|
108
|
+
shutil.move(str(self.db_path), str(backup_path))
|
|
109
|
+
print(f"Corrupted database backed up to: {backup_path}")
|
|
110
|
+
|
|
111
|
+
# Create fresh database
|
|
112
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
conn = sqlite3.connect(self.db_path)
|
|
114
|
+
self._create_schema(conn)
|
|
115
|
+
conn.close()
|
|
116
|
+
print(f"Fresh database created at: {self.db_path}")
|
|
117
|
+
|
|
118
|
+
def add_manual_path(self, path: Path) -> None:
|
|
119
|
+
"""Add a manual project path.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
path: Path to add
|
|
123
|
+
"""
|
|
124
|
+
conn = sqlite3.connect(self.db_path)
|
|
125
|
+
cursor = conn.cursor()
|
|
126
|
+
|
|
127
|
+
cursor.execute(
|
|
128
|
+
"INSERT OR REPLACE INTO manual_projects (path, added_at) VALUES (?, ?)",
|
|
129
|
+
(str(path), datetime.now().isoformat()),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
conn.commit()
|
|
133
|
+
conn.close()
|
|
134
|
+
|
|
135
|
+
def remove_manual_path(self, path: str) -> None:
|
|
136
|
+
"""Remove a manual project path.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
path: Path string to remove
|
|
140
|
+
"""
|
|
141
|
+
conn = sqlite3.connect(self.db_path)
|
|
142
|
+
cursor = conn.cursor()
|
|
143
|
+
|
|
144
|
+
cursor.execute("DELETE FROM manual_projects WHERE path = ?", (path,))
|
|
145
|
+
|
|
146
|
+
conn.commit()
|
|
147
|
+
conn.close()
|
|
148
|
+
|
|
149
|
+
def get_manual_paths(self) -> List[str]:
|
|
150
|
+
"""Get all manual project paths.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of path strings
|
|
154
|
+
"""
|
|
155
|
+
conn = sqlite3.connect(self.db_path)
|
|
156
|
+
cursor = conn.cursor()
|
|
157
|
+
|
|
158
|
+
cursor.execute("SELECT path FROM manual_projects ORDER BY path")
|
|
159
|
+
paths = [row[0] for row in cursor.fetchall()]
|
|
160
|
+
|
|
161
|
+
conn.close()
|
|
162
|
+
return paths
|
|
163
|
+
|
|
164
|
+
def get_manual_projects(self) -> List[Project]:
|
|
165
|
+
"""Get manual projects, auto-removing non-existent paths.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
List of valid Project instances
|
|
169
|
+
"""
|
|
170
|
+
paths = self.get_manual_paths()
|
|
171
|
+
valid_projects = []
|
|
172
|
+
invalid_paths = []
|
|
173
|
+
|
|
174
|
+
for path_str in paths:
|
|
175
|
+
# Don't resolve symlinks - keep the original path
|
|
176
|
+
# This ensures projects show up in the tree under their symlink location
|
|
177
|
+
path = Path(path_str).expanduser()
|
|
178
|
+
|
|
179
|
+
if path.exists() and path.is_dir():
|
|
180
|
+
project = Project.from_path(path, is_manual=True)
|
|
181
|
+
valid_projects.append(project)
|
|
182
|
+
else:
|
|
183
|
+
invalid_paths.append(path_str)
|
|
184
|
+
print(f"Removing non-existent manual path: {path_str}")
|
|
185
|
+
|
|
186
|
+
# Clean up invalid paths
|
|
187
|
+
for path_str in invalid_paths:
|
|
188
|
+
self.remove_manual_path(path_str)
|
|
189
|
+
|
|
190
|
+
return valid_projects
|
|
191
|
+
|
|
192
|
+
def set_last_opened(self, path: Path) -> None:
|
|
193
|
+
"""Set the last opened project.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
path: Path to the project
|
|
197
|
+
"""
|
|
198
|
+
conn = sqlite3.connect(self.db_path)
|
|
199
|
+
cursor = conn.cursor()
|
|
200
|
+
|
|
201
|
+
# Clear existing and set new (single row table)
|
|
202
|
+
cursor.execute("DELETE FROM last_opened")
|
|
203
|
+
cursor.execute("INSERT INTO last_opened (path) VALUES (?)", (str(path),))
|
|
204
|
+
|
|
205
|
+
# Also record in access history
|
|
206
|
+
cursor.execute(
|
|
207
|
+
"INSERT INTO access_history (path, accessed_at) VALUES (?, ?)",
|
|
208
|
+
(str(path), datetime.now().isoformat()),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
conn.commit()
|
|
212
|
+
conn.close()
|
|
213
|
+
|
|
214
|
+
def get_last_opened(self) -> Optional[str]:
|
|
215
|
+
"""Get the last opened project path.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Path string or None if no history
|
|
219
|
+
"""
|
|
220
|
+
conn = sqlite3.connect(self.db_path)
|
|
221
|
+
cursor = conn.cursor()
|
|
222
|
+
|
|
223
|
+
cursor.execute("SELECT path FROM last_opened LIMIT 1")
|
|
224
|
+
row = cursor.fetchone()
|
|
225
|
+
|
|
226
|
+
conn.close()
|
|
227
|
+
result: Optional[str] = row[0] if row else None
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
def get_default_selection_index(self, projects: List[Project]) -> int:
|
|
231
|
+
"""Get the index of the last opened project in the list.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
projects: Sorted list of projects
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Index of last opened project, or 0 if not found
|
|
238
|
+
"""
|
|
239
|
+
last_opened = self.get_last_opened()
|
|
240
|
+
if not last_opened:
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
for i, project in enumerate(projects):
|
|
244
|
+
if str(project.path) == last_opened:
|
|
245
|
+
return i
|
|
246
|
+
|
|
247
|
+
return 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""User interface components for claude-launcher."""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Preview helper script for fzf."""
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from claude_launcher.ui.preview import generate_preview
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
"""Generate preview from fzf selection line."""
|
|
12
|
+
if len(sys.argv) < 2:
|
|
13
|
+
print("No selection provided")
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
line = sys.argv[1]
|
|
17
|
+
|
|
18
|
+
# Extract path from formatted line
|
|
19
|
+
# Format is: "absolute_path\t\ttree_display" or special markers
|
|
20
|
+
# Special marker: __SPACE__ (spacing line)
|
|
21
|
+
parts = line.split("\t\t", 1)
|
|
22
|
+
|
|
23
|
+
if len(parts) == 2 and parts[0] != "__SPACE__":
|
|
24
|
+
# Normal line - first field is absolute path (project or directory)
|
|
25
|
+
path_str = parts[0]
|
|
26
|
+
else:
|
|
27
|
+
# Spacing line or malformed - show nothing
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
path = Path(path_str).expanduser().resolve()
|
|
32
|
+
|
|
33
|
+
# Show full path at top with divider
|
|
34
|
+
print(f"{path}")
|
|
35
|
+
print("━" * 80)
|
|
36
|
+
print()
|
|
37
|
+
|
|
38
|
+
# Check if it's a directory (folder header) or project
|
|
39
|
+
if path.is_dir() and not (path / ".git").exists():
|
|
40
|
+
# Directory header - show directory contents
|
|
41
|
+
print("Contents:")
|
|
42
|
+
print("─" * 40)
|
|
43
|
+
try:
|
|
44
|
+
# Separate directories and files
|
|
45
|
+
dirs = []
|
|
46
|
+
files = []
|
|
47
|
+
for item in path.iterdir():
|
|
48
|
+
if item.is_dir():
|
|
49
|
+
dirs.append(f"📁 {item.name}/")
|
|
50
|
+
else:
|
|
51
|
+
files.append(f"📄 {item.name}")
|
|
52
|
+
|
|
53
|
+
# Sort each group and combine (folders first)
|
|
54
|
+
items = sorted(dirs) + sorted(files)
|
|
55
|
+
|
|
56
|
+
# Show all items (no limit)
|
|
57
|
+
if items:
|
|
58
|
+
print("\n".join(items))
|
|
59
|
+
else:
|
|
60
|
+
print("(empty directory)")
|
|
61
|
+
except PermissionError:
|
|
62
|
+
print("(permission denied)")
|
|
63
|
+
else:
|
|
64
|
+
# Project directory - show full preview
|
|
65
|
+
preview = generate_preview(path, show_git_status=True)
|
|
66
|
+
print(preview.format())
|
|
67
|
+
except Exception as e:
|
|
68
|
+
print(f"Error generating preview: {e}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
main()
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Directory browser for claude-launcher."""
|
|
2
|
+
|
|
3
|
+
import subprocess # nosec B404
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING, Optional
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from claude_launcher.core.storage import Storage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def browse_directory(start_path: Optional[Path] = None) -> Optional[Path]:
|
|
12
|
+
"""Interactive directory browser for selecting a path.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
start_path: Starting directory (default: ~/projects or ~)
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Selected directory Path or None if cancelled
|
|
19
|
+
"""
|
|
20
|
+
# Start at ~/projects if it exists, otherwise ~
|
|
21
|
+
if start_path is None:
|
|
22
|
+
projects_dir = Path.home() / "projects"
|
|
23
|
+
start_path = projects_dir if projects_dir.exists() else Path.home()
|
|
24
|
+
|
|
25
|
+
current_path = start_path.resolve()
|
|
26
|
+
|
|
27
|
+
while True:
|
|
28
|
+
# Get directory contents
|
|
29
|
+
try:
|
|
30
|
+
items = []
|
|
31
|
+
|
|
32
|
+
# Add special navigation options
|
|
33
|
+
items.append(".")
|
|
34
|
+
items.append("..")
|
|
35
|
+
|
|
36
|
+
# Add subdirectories
|
|
37
|
+
dirs = []
|
|
38
|
+
for item in sorted(current_path.iterdir()):
|
|
39
|
+
if item.is_dir():
|
|
40
|
+
# Show symlink indicator
|
|
41
|
+
if item.is_symlink():
|
|
42
|
+
dirs.append(item.name + "@")
|
|
43
|
+
else:
|
|
44
|
+
dirs.append(item.name)
|
|
45
|
+
|
|
46
|
+
items.extend(dirs)
|
|
47
|
+
|
|
48
|
+
except PermissionError:
|
|
49
|
+
print(f"Permission denied: {current_path}")
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
# Build header
|
|
53
|
+
header = f"""╭─────────────────────────────────────────╮
|
|
54
|
+
│ Directory Browser │
|
|
55
|
+
╰─────────────────────────────────────────╯
|
|
56
|
+
Current: {current_path}
|
|
57
|
+
|
|
58
|
+
. = Select this directory
|
|
59
|
+
.. = Go up one level
|
|
60
|
+
@ suffix = symlink"""
|
|
61
|
+
|
|
62
|
+
# Build preview command
|
|
63
|
+
preview_cmd = f"""
|
|
64
|
+
if [[ {{}} == '.' ]]; then
|
|
65
|
+
echo 'Select: {current_path}'
|
|
66
|
+
elif [[ {{}} == '..' ]]; then
|
|
67
|
+
echo 'Go to: {current_path.parent}'
|
|
68
|
+
else
|
|
69
|
+
target='{current_path}/{{}}'
|
|
70
|
+
target=${{target%@}}
|
|
71
|
+
if [[ -L "$target" ]]; then
|
|
72
|
+
echo "Symlink to: $(readlink -f "$target")"
|
|
73
|
+
echo ''
|
|
74
|
+
fi
|
|
75
|
+
ls -la "$target" 2>/dev/null | head -20
|
|
76
|
+
fi
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
# Run fzf
|
|
80
|
+
try:
|
|
81
|
+
process = subprocess.Popen( # nosec B603, B607
|
|
82
|
+
[
|
|
83
|
+
"fzf",
|
|
84
|
+
"--height=80%",
|
|
85
|
+
"--layout=reverse",
|
|
86
|
+
"--border=rounded",
|
|
87
|
+
f"--header={header}",
|
|
88
|
+
"--header-first",
|
|
89
|
+
"--prompt=> ",
|
|
90
|
+
f"--preview={preview_cmd}",
|
|
91
|
+
"--preview-window=right:60%:wrap",
|
|
92
|
+
],
|
|
93
|
+
stdin=subprocess.PIPE,
|
|
94
|
+
stdout=subprocess.PIPE,
|
|
95
|
+
text=True,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
stdout, _ = process.communicate(input="\n".join(items))
|
|
99
|
+
|
|
100
|
+
if process.returncode != 0:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
selected = stdout.strip()
|
|
104
|
+
if not selected:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
print("Error: fzf not found")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# Handle selection
|
|
112
|
+
if selected == ".":
|
|
113
|
+
# Select current directory
|
|
114
|
+
return current_path
|
|
115
|
+
elif selected == "..":
|
|
116
|
+
# Navigate up
|
|
117
|
+
current_path = current_path.parent
|
|
118
|
+
else:
|
|
119
|
+
# Navigate into subdirectory (strip @ if symlink)
|
|
120
|
+
dir_name = selected.rstrip("@")
|
|
121
|
+
current_path = current_path / dir_name
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def remove_manual_path(storage: "Storage") -> bool:
|
|
125
|
+
"""Interactive removal of manual paths.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
storage: Storage instance
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if a path was removed, False otherwise
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
manual_paths = storage.get_manual_paths()
|
|
135
|
+
|
|
136
|
+
if not manual_paths:
|
|
137
|
+
print("No manual paths to remove.")
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
# Build header
|
|
141
|
+
count = len(manual_paths)
|
|
142
|
+
header = f"""╭─────────────────────────────────────────╮
|
|
143
|
+
│ Remove Manual Path ({count} total) │
|
|
144
|
+
╰─────────────────────────────────────────╯
|
|
145
|
+
Select path to remove (Esc to cancel)"""
|
|
146
|
+
|
|
147
|
+
# Build preview command
|
|
148
|
+
preview_cmd = """
|
|
149
|
+
echo 'Will remove:'
|
|
150
|
+
echo ''
|
|
151
|
+
echo ' {}'
|
|
152
|
+
echo ''
|
|
153
|
+
if [[ -d '{}' ]]; then
|
|
154
|
+
echo 'Directory exists'
|
|
155
|
+
else
|
|
156
|
+
echo 'Directory does not exist'
|
|
157
|
+
fi
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
# Run fzf
|
|
161
|
+
try:
|
|
162
|
+
process = subprocess.Popen( # nosec B603, B607
|
|
163
|
+
[
|
|
164
|
+
"fzf",
|
|
165
|
+
"--height=50%",
|
|
166
|
+
"--layout=reverse",
|
|
167
|
+
"--border=rounded",
|
|
168
|
+
f"--header={header}",
|
|
169
|
+
"--header-first",
|
|
170
|
+
"--prompt=Remove > ",
|
|
171
|
+
f"--preview={preview_cmd}",
|
|
172
|
+
"--preview-window=right:60%:wrap",
|
|
173
|
+
],
|
|
174
|
+
stdin=subprocess.PIPE,
|
|
175
|
+
stdout=subprocess.PIPE,
|
|
176
|
+
text=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
stdout, _ = process.communicate(input="\n".join(manual_paths))
|
|
180
|
+
|
|
181
|
+
if process.returncode != 0:
|
|
182
|
+
print("Cancelled.")
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
selected = stdout.strip()
|
|
186
|
+
if not selected:
|
|
187
|
+
print("Cancelled.")
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
except FileNotFoundError:
|
|
191
|
+
print("Error: fzf not found")
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
# Remove the path
|
|
195
|
+
storage.remove_manual_path(selected)
|
|
196
|
+
return True
|