gamr 0.1.1__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.
- gamr/__init__.py +3 -0
- gamr/app.py +498 -0
- gamr/commands.py +52 -0
- gamr/config.py +33 -0
- gamr/gamr.tcss +13 -0
- gamr/models.py +59 -0
- gamr/py.typed +0 -0
- gamr/services/__init__.py +3 -0
- gamr/services/diff_parser.py +76 -0
- gamr/services/file_index.py +74 -0
- gamr/services/file_scanner.py +209 -0
- gamr/services/filter.py +92 -0
- gamr/services/git_provider.py +220 -0
- gamr/services/icons.py +72 -0
- gamr/state.py +213 -0
- gamr/widgets/__init__.py +3 -0
- gamr/widgets/file_tree_table.py +657 -0
- gamr/widgets/filter_bar.py +104 -0
- gamr/widgets/preview_pane.py +678 -0
- gamr/widgets/split.py +100 -0
- gamr/widgets/tree_data.py +124 -0
- gamr-0.1.1.dist-info/METADATA +12 -0
- gamr-0.1.1.dist-info/RECORD +26 -0
- gamr-0.1.1.dist-info/WHEEL +4 -0
- gamr-0.1.1.dist-info/entry_points.txt +2 -0
- gamr-0.1.1.dist-info/licenses/LICENSE +21 -0
gamr/__init__.py
ADDED
gamr/app.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""Gamr TUI application — main entry point and orchestration.
|
|
2
|
+
|
|
3
|
+
Lifecycle: loads persisted state, initializes services (git, scanner, index),
|
|
4
|
+
composes the UI (filter bar, split pane with tree table + preview), starts
|
|
5
|
+
the file watcher, and manages background workers for expensive git data.
|
|
6
|
+
|
|
7
|
+
Data flow on file change:
|
|
8
|
+
watchdog → queue → _poll_filesystem worker → _handle_file_changes (main thread)
|
|
9
|
+
→ FileIndex.build() → _apply_filters() → tree.load_entries() → _sync_table()
|
|
10
|
+
|
|
11
|
+
Data flow on git state change (.git/index, HEAD):
|
|
12
|
+
_GitHandler → GIT_STATE_CHANGED event → same path as above
|
|
13
|
+
→ only refreshes preview if previewed file's git_status actually changed
|
|
14
|
+
|
|
15
|
+
Domain model owns all preview decisions:
|
|
16
|
+
_previewed_path — which file is shown (prevents spurious switches)
|
|
17
|
+
_previewed_git_status — last-rendered git status (prevents unnecessary re-renders)
|
|
18
|
+
_scroll_positions — per-file source line cache (persists across file switches)
|
|
19
|
+
restore_line — passed atomically through show methods (no post-render scroll)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from textual import work
|
|
25
|
+
from textual.app import App, ComposeResult
|
|
26
|
+
from textual.binding import Binding
|
|
27
|
+
from textual.css.query import NoMatches
|
|
28
|
+
from textual.widgets import Footer, Header
|
|
29
|
+
from textual.worker import get_current_worker
|
|
30
|
+
|
|
31
|
+
from gamr.commands import GamrCommands
|
|
32
|
+
from gamr.config import TIMESTAMP_REFRESH_INTERVAL, WATCHER_POLL_INTERVAL
|
|
33
|
+
from gamr.models import DiffMode, FileEntry, GitStatus
|
|
34
|
+
from gamr.services.file_index import FileIndex
|
|
35
|
+
from gamr.services.file_scanner import FileScanner
|
|
36
|
+
from gamr.services.filter import filter_by_status, fuzzy_filter
|
|
37
|
+
from gamr.services.git_provider import DulwichGitProvider, NullGitProvider
|
|
38
|
+
from gamr.state import AppState
|
|
39
|
+
from gamr.widgets.file_tree_table import FileTreeTable
|
|
40
|
+
from gamr.widgets.filter_bar import FilterBar
|
|
41
|
+
from gamr.widgets.preview_pane import PreviewPane
|
|
42
|
+
from gamr.widgets.split import HorizontalSplit, SplitHandle
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GamrApp(App):
|
|
46
|
+
"""A git-aware file browser TUI."""
|
|
47
|
+
|
|
48
|
+
COMMANDS = App.COMMANDS | {GamrCommands}
|
|
49
|
+
CSS_PATH = "gamr.tcss"
|
|
50
|
+
TITLE = "Gamr"
|
|
51
|
+
|
|
52
|
+
# All bindings use priority=True so they work regardless of which widget has focus.
|
|
53
|
+
# See docs/UI_DESIGN.md for the full interaction specification.
|
|
54
|
+
BINDINGS = [
|
|
55
|
+
# Navigation & focus
|
|
56
|
+
Binding("tab", "switch_pane", "Switch pane", show=True, priority=True),
|
|
57
|
+
Binding("ctrl+f", "focus_filter", "Filter", show=True, priority=True),
|
|
58
|
+
# Modes
|
|
59
|
+
Binding("f", "toggle_follow", "Follow", show=True, priority=True),
|
|
60
|
+
Binding("v", "cycle_view", "View mode", show=True, priority=True),
|
|
61
|
+
Binding("d", "toggle_diff", "Diff toggle", show=True, priority=True),
|
|
62
|
+
Binding("D", "toggle_diff_reverse", show=False, priority=True),
|
|
63
|
+
# Columns
|
|
64
|
+
Binding("b", "toggle_blame", "Blame cols", show=True, priority=True),
|
|
65
|
+
Binding("1", "toggle_col('status')", "Status col", show=False, priority=True),
|
|
66
|
+
Binding("2", "toggle_col('lines')", "Lines col", show=False, priority=True),
|
|
67
|
+
Binding("3", "toggle_col('size')", "Size col", show=False, priority=True),
|
|
68
|
+
Binding("4", "toggle_col('mtime')", "Mtime col", show=False, priority=True),
|
|
69
|
+
Binding("5", "toggle_col('author')", "Author col", show=False, priority=True),
|
|
70
|
+
Binding("6", "toggle_col('git_time')", "Git time col", show=False, priority=True),
|
|
71
|
+
# Filters
|
|
72
|
+
Binding("m", "toggle_modified", "Modified", show=True, priority=True),
|
|
73
|
+
# App lifecycle
|
|
74
|
+
Binding("q", "quit", "Quit", show=True, priority=True),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
# -------------------------------------------------------------------------
|
|
78
|
+
# Lifecycle
|
|
79
|
+
# -------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def __init__(self, path: Path | None = None, **kwargs) -> None:
|
|
82
|
+
super().__init__(**kwargs)
|
|
83
|
+
self.target_path = (path or Path.cwd()).resolve()
|
|
84
|
+
self._all_entries: list[FileEntry] = []
|
|
85
|
+
# Load persisted state from ~/.config/gamr/state.json
|
|
86
|
+
self._state = AppState.load(self.target_path)
|
|
87
|
+
self._diff_mode: DiffMode = self._state.diff_mode
|
|
88
|
+
self._follow_mode: bool = False
|
|
89
|
+
self._previewed_path: Path | None = None
|
|
90
|
+
self._previewed_git_status = None
|
|
91
|
+
self._scroll_positions: dict[Path, int] = {} # path → source line
|
|
92
|
+
|
|
93
|
+
def compose(self) -> ComposeResult:
|
|
94
|
+
yield Header()
|
|
95
|
+
yield FilterBar()
|
|
96
|
+
with HorizontalSplit(id="main"):
|
|
97
|
+
yield FileTreeTable(id="left-pane")
|
|
98
|
+
yield SplitHandle()
|
|
99
|
+
yield PreviewPane(id="right-pane")
|
|
100
|
+
yield Footer()
|
|
101
|
+
|
|
102
|
+
def on_mount(self) -> None:
|
|
103
|
+
# --- Initialize services ---
|
|
104
|
+
git = DulwichGitProvider(self.target_path)
|
|
105
|
+
if not git.is_git_repo():
|
|
106
|
+
git = NullGitProvider()
|
|
107
|
+
scanner = FileScanner(self.target_path, ignore_filter=git.get_ignore_filter())
|
|
108
|
+
self._git = git
|
|
109
|
+
self._scanner = scanner
|
|
110
|
+
self._file_index = FileIndex(scanner, git)
|
|
111
|
+
self._all_entries = self._file_index.build()
|
|
112
|
+
|
|
113
|
+
# --- Restore widget state from persisted session ---
|
|
114
|
+
tree = self.query_one(FileTreeTable)
|
|
115
|
+
self._update_global_mtime_range(tree)
|
|
116
|
+
filter_bar = self.query_one(FilterBar)
|
|
117
|
+
split = self.query_one(HorizontalSplit)
|
|
118
|
+
self._state.apply_to_widgets(tree, filter_bar, split)
|
|
119
|
+
filtered = self._apply_filters(filter_bar.active_statuses, filter_bar.search_query)
|
|
120
|
+
tree.load_entries(
|
|
121
|
+
filtered,
|
|
122
|
+
self.target_path,
|
|
123
|
+
collapsed_dirs=self._state.collapsed_dirs,
|
|
124
|
+
)
|
|
125
|
+
if self._state.selected_path:
|
|
126
|
+
tree.restore_cursor(Path(self._state.selected_path))
|
|
127
|
+
|
|
128
|
+
# --- Start background services ---
|
|
129
|
+
self._scanner.start_watching(git_root=self._git.git_dir if self._git.is_git_repo() else None)
|
|
130
|
+
self._poll_filesystem()
|
|
131
|
+
|
|
132
|
+
# --- Git-specific UI adjustments ---
|
|
133
|
+
if not self._git.is_git_repo():
|
|
134
|
+
tree.show_status = False
|
|
135
|
+
tree.show_lines = False
|
|
136
|
+
for btn in self.query(".filter-btn"):
|
|
137
|
+
btn.display = False
|
|
138
|
+
else:
|
|
139
|
+
self._load_diff_stats()
|
|
140
|
+
|
|
141
|
+
tree.focus()
|
|
142
|
+
self.set_interval(TIMESTAMP_REFRESH_INTERVAL, self._refresh_timestamps)
|
|
143
|
+
|
|
144
|
+
def on_unmount(self) -> None:
|
|
145
|
+
"""Release native filesystem watcher resources during every shutdown path."""
|
|
146
|
+
scanner = getattr(self, "_scanner", None)
|
|
147
|
+
if scanner is not None:
|
|
148
|
+
scanner.stop()
|
|
149
|
+
|
|
150
|
+
def watch_theme(self, theme: str) -> None:
|
|
151
|
+
"""Switch syntax highlight theme when Textual theme changes."""
|
|
152
|
+
try:
|
|
153
|
+
is_dark = self.current_theme.dark if self.current_theme else True
|
|
154
|
+
except Exception:
|
|
155
|
+
is_dark = True
|
|
156
|
+
preview = self.query_one(PreviewPane)
|
|
157
|
+
preview.syntax_theme = "monokai" if is_dark else "default"
|
|
158
|
+
# Re-render current preview with new theme
|
|
159
|
+
if self._previewed_path:
|
|
160
|
+
entry = self._file_index.entries.get(self._previewed_path)
|
|
161
|
+
if entry and self._is_previewable(entry):
|
|
162
|
+
preview.invalidate()
|
|
163
|
+
source_line = preview.get_source_line_at_scroll()
|
|
164
|
+
self._show_preview_for(entry, scroll_to_top=False, restore_line=source_line)
|
|
165
|
+
|
|
166
|
+
# -------------------------------------------------------------------------
|
|
167
|
+
# File watching and live updates
|
|
168
|
+
# -------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
@work(thread=True, group="watcher")
|
|
171
|
+
def _poll_filesystem(self) -> None:
|
|
172
|
+
"""Long-running thread worker: drains file change events from the scanner queue."""
|
|
173
|
+
import time
|
|
174
|
+
|
|
175
|
+
from gamr.services.file_scanner import ChangeType
|
|
176
|
+
|
|
177
|
+
worker = get_current_worker()
|
|
178
|
+
while not worker.is_cancelled:
|
|
179
|
+
time.sleep(WATCHER_POLL_INTERVAL)
|
|
180
|
+
self._scanner.poll_changes()
|
|
181
|
+
changes = self._scanner.drain()
|
|
182
|
+
if changes:
|
|
183
|
+
git_changed = any(c.change_type == ChangeType.GIT_STATE_CHANGED for c in changes)
|
|
184
|
+
changed_paths = [c.path for c in changes if c.change_type != ChangeType.GIT_STATE_CHANGED]
|
|
185
|
+
self.call_from_thread(self._handle_file_changes, changed_paths, git_changed)
|
|
186
|
+
|
|
187
|
+
def _handle_file_changes(self, changed_paths: list[Path] | None = None, git_changed: bool = False) -> None:
|
|
188
|
+
"""Respond to filesystem changes: rebuild index, re-filter, sync table."""
|
|
189
|
+
tree = self.query_one(FileTreeTable)
|
|
190
|
+
collapsed = tree.get_collapsed_dirs()
|
|
191
|
+
|
|
192
|
+
# Auto-expand parents of changed files so they're always visible
|
|
193
|
+
if changed_paths:
|
|
194
|
+
for path in changed_paths:
|
|
195
|
+
parent = path.parent
|
|
196
|
+
while parent != self.target_path:
|
|
197
|
+
try:
|
|
198
|
+
collapsed.discard(str(parent.relative_to(self.target_path)))
|
|
199
|
+
except ValueError:
|
|
200
|
+
break
|
|
201
|
+
parent = parent.parent
|
|
202
|
+
|
|
203
|
+
# Cancel background workers that reference old entries before rebuilding
|
|
204
|
+
self.workers.cancel_group(self, "diff_stats")
|
|
205
|
+
self.workers.cancel_group(self, "blame")
|
|
206
|
+
|
|
207
|
+
# Rebuild index (picks up new git statuses, file additions/deletions)
|
|
208
|
+
self._all_entries = self._file_index.build()
|
|
209
|
+
self._update_global_mtime_range(tree)
|
|
210
|
+
|
|
211
|
+
# Re-apply filters and sync the table (incremental — only changed rows update)
|
|
212
|
+
filter_bar = self.query_one(FilterBar)
|
|
213
|
+
filtered = self._apply_filters(filter_bar.active_statuses, filter_bar.search_query)
|
|
214
|
+
tree.load_entries(filtered, self.target_path, collapsed_dirs=collapsed)
|
|
215
|
+
|
|
216
|
+
# Update preview if the currently previewed file's content or git status changed
|
|
217
|
+
if self._previewed_path:
|
|
218
|
+
file_content_changed = changed_paths and self._previewed_path in set(changed_paths)
|
|
219
|
+
entry = self._file_index.entries.get(self._previewed_path)
|
|
220
|
+
# On git state change, only refresh if this file's status actually differs
|
|
221
|
+
git_status_changed = False
|
|
222
|
+
if git_changed and entry:
|
|
223
|
+
old_status = getattr(self, "_previewed_git_status", None)
|
|
224
|
+
if entry.git_status != old_status:
|
|
225
|
+
git_status_changed = True
|
|
226
|
+
if (file_content_changed or git_status_changed) and entry and self._is_previewable(entry):
|
|
227
|
+
preview = self.query_one(PreviewPane)
|
|
228
|
+
source_line = preview.get_source_line_at_scroll()
|
|
229
|
+
preview.invalidate()
|
|
230
|
+
self._show_preview_for(entry, scroll_to_top=False, restore_line=source_line)
|
|
231
|
+
if entry:
|
|
232
|
+
self._previewed_git_status = entry.git_status
|
|
233
|
+
|
|
234
|
+
# Follow mode: jump to the last changed file
|
|
235
|
+
if self._follow_mode and changed_paths:
|
|
236
|
+
follow_path = changed_paths[-1]
|
|
237
|
+
tree.restore_cursor(follow_path)
|
|
238
|
+
self._previewed_path = follow_path
|
|
239
|
+
self._show_followed_path(follow_path)
|
|
240
|
+
|
|
241
|
+
# Re-trigger background workers (only when git state changed or files modified)
|
|
242
|
+
if self._git.is_git_repo() and (git_changed or changed_paths):
|
|
243
|
+
self._load_diff_stats()
|
|
244
|
+
if tree.show_author or tree.show_git_time:
|
|
245
|
+
self._load_blame_data()
|
|
246
|
+
|
|
247
|
+
# -------------------------------------------------------------------------
|
|
248
|
+
# Preview pane management
|
|
249
|
+
# -------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def on_file_tree_table_node_highlighted(self, event: FileTreeTable.NodeHighlighted) -> None:
|
|
252
|
+
"""Domain decision: only update preview when user navigates to a new file."""
|
|
253
|
+
entry = event.entry
|
|
254
|
+
if entry is None or not self._is_previewable(entry):
|
|
255
|
+
return
|
|
256
|
+
if entry.path == self._previewed_path:
|
|
257
|
+
return
|
|
258
|
+
# Save scroll position of the file we're leaving
|
|
259
|
+
try:
|
|
260
|
+
preview = self.query_one(PreviewPane)
|
|
261
|
+
if self._previewed_path:
|
|
262
|
+
self._scroll_positions[self._previewed_path] = preview.get_source_line_at_scroll()
|
|
263
|
+
except NoMatches:
|
|
264
|
+
pass
|
|
265
|
+
self._previewed_path = entry.path
|
|
266
|
+
self._previewed_git_status = entry.git_status
|
|
267
|
+
try:
|
|
268
|
+
saved = self._scroll_positions.get(entry.path, 0)
|
|
269
|
+
self._show_preview_for(entry, restore_line=saved)
|
|
270
|
+
except NoMatches:
|
|
271
|
+
pass # Preview pane may not be mounted yet during startup
|
|
272
|
+
|
|
273
|
+
def _show_preview_for(self, entry: FileEntry, *, scroll_to_top: bool = True, restore_line: int = 0) -> None:
|
|
274
|
+
"""Render file content or diff in the preview pane based on current diff mode."""
|
|
275
|
+
preview = self.query_one(PreviewPane)
|
|
276
|
+
preview.show_diff = self._diff_mode
|
|
277
|
+
is_diffable = entry.git_status and self._git.is_git_repo()
|
|
278
|
+
|
|
279
|
+
if is_diffable and self._diff_mode == DiffMode.UNIFIED:
|
|
280
|
+
diff = self._git.get_diff(entry.path)
|
|
281
|
+
if diff:
|
|
282
|
+
preview.show_diff_content(diff, path=entry.path, scroll_to_top=scroll_to_top, restore_line=restore_line)
|
|
283
|
+
return
|
|
284
|
+
elif is_diffable and self._diff_mode == DiffMode.FULL:
|
|
285
|
+
diff = self._git.get_diff(entry.path)
|
|
286
|
+
if diff:
|
|
287
|
+
preview.show_full_diff(entry.path, diff, scroll_to_top=scroll_to_top, restore_line=restore_line)
|
|
288
|
+
return
|
|
289
|
+
elif is_diffable and self._diff_mode == DiffMode.GUTTER:
|
|
290
|
+
diff = self._git.get_diff(entry.path)
|
|
291
|
+
if diff:
|
|
292
|
+
preview.show_gutter_diff(entry.path, diff, scroll_to_top=scroll_to_top, restore_line=restore_line)
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
preview.show_file(entry.path, scroll_to_top=scroll_to_top, restore_line=restore_line)
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _is_previewable(entry: FileEntry) -> bool:
|
|
299
|
+
"""Return whether an entry has file contents or a deletion diff to show."""
|
|
300
|
+
return entry.path.is_file() or entry.git_status in {
|
|
301
|
+
GitStatus.DELETED,
|
|
302
|
+
GitStatus.STAGED_DELETED,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
def _show_followed_path(self, path: Path) -> None:
|
|
306
|
+
"""Force preview update for a followed file; scroll to first diff hunk."""
|
|
307
|
+
import re
|
|
308
|
+
|
|
309
|
+
entry = self._file_index.entries.get(path)
|
|
310
|
+
if not entry or not self._is_previewable(entry):
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
# Scroll to the first diff hunk if the file is git-modified
|
|
314
|
+
restore_line = 0
|
|
315
|
+
if entry.git_status and self._git.is_git_repo():
|
|
316
|
+
diff = self._git.get_diff(path)
|
|
317
|
+
if diff:
|
|
318
|
+
m = re.search(r"@@ [^+]*\+(\d+)", diff)
|
|
319
|
+
if m:
|
|
320
|
+
restore_line = int(m.group(1))
|
|
321
|
+
|
|
322
|
+
preview = self.query_one(PreviewPane)
|
|
323
|
+
preview.invalidate()
|
|
324
|
+
self._show_preview_for(entry, restore_line=restore_line)
|
|
325
|
+
|
|
326
|
+
# -------------------------------------------------------------------------
|
|
327
|
+
# Background data workers
|
|
328
|
+
# -------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
@work(thread=True, group="blame")
|
|
331
|
+
def _load_blame_data(self) -> None:
|
|
332
|
+
"""Populate last_author and last_git_modified for all entries (expensive)."""
|
|
333
|
+
worker = get_current_worker()
|
|
334
|
+
for path, _entry in list(self._file_index.entries.items()):
|
|
335
|
+
if worker.is_cancelled:
|
|
336
|
+
return
|
|
337
|
+
self._file_index.update_blame(path)
|
|
338
|
+
if not worker.is_cancelled:
|
|
339
|
+
self.call_from_thread(self._refresh_tree_labels)
|
|
340
|
+
|
|
341
|
+
@work(thread=True, group="diff_stats")
|
|
342
|
+
def _load_diff_stats(self) -> None:
|
|
343
|
+
"""Populate lines_added/removed for modified files."""
|
|
344
|
+
worker = get_current_worker()
|
|
345
|
+
for path, entry in list(self._file_index.entries.items()):
|
|
346
|
+
if worker.is_cancelled:
|
|
347
|
+
return
|
|
348
|
+
if entry.git_status:
|
|
349
|
+
self._file_index.update_diff_stats(path)
|
|
350
|
+
if not worker.is_cancelled:
|
|
351
|
+
self.call_from_thread(self._refresh_tree_labels)
|
|
352
|
+
|
|
353
|
+
def _refresh_tree_labels(self) -> None:
|
|
354
|
+
"""Refresh tree data after a background worker completes."""
|
|
355
|
+
self.query_one(FileTreeTable).refresh_data()
|
|
356
|
+
|
|
357
|
+
def _refresh_timestamps(self) -> None:
|
|
358
|
+
"""Called every 10s to update relative time displays in-place."""
|
|
359
|
+
tree = self.query_one(FileTreeTable)
|
|
360
|
+
if tree.show_mtime or tree.show_git_time:
|
|
361
|
+
tree.refresh_time_cells()
|
|
362
|
+
|
|
363
|
+
# -------------------------------------------------------------------------
|
|
364
|
+
# Filter logic
|
|
365
|
+
# -------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
def on_filter_bar_filters_changed(self, event: FilterBar.FiltersChanged) -> None:
|
|
368
|
+
"""Re-filter and reload tree when filter buttons or search text change."""
|
|
369
|
+
# Debounce: cancel pending filter and schedule a new one
|
|
370
|
+
if hasattr(self, "_filter_timer") and self._filter_timer:
|
|
371
|
+
self._filter_timer.stop()
|
|
372
|
+
self._pending_filter = (event.active_statuses, event.search_query)
|
|
373
|
+
self._filter_timer = self.set_timer(0.15, self._apply_pending_filter)
|
|
374
|
+
|
|
375
|
+
def _apply_pending_filter(self) -> None:
|
|
376
|
+
"""Apply the debounced filter after 150ms of inactivity."""
|
|
377
|
+
if not hasattr(self, "_pending_filter"):
|
|
378
|
+
return
|
|
379
|
+
statuses, query = self._pending_filter
|
|
380
|
+
tree = self.query_one(FileTreeTable)
|
|
381
|
+
collapsed = tree.get_collapsed_dirs()
|
|
382
|
+
filtered = self._apply_filters(statuses, query)
|
|
383
|
+
|
|
384
|
+
# Skip rebuild if result set hasn't changed
|
|
385
|
+
new_paths = {e.path for e in filtered}
|
|
386
|
+
if hasattr(self, "_last_filtered_paths") and new_paths == self._last_filtered_paths:
|
|
387
|
+
return
|
|
388
|
+
self._last_filtered_paths = new_paths
|
|
389
|
+
|
|
390
|
+
tree.load_entries(filtered, self.target_path, collapsed_dirs=collapsed)
|
|
391
|
+
|
|
392
|
+
def _update_global_mtime_range(self, tree: FileTreeTable) -> None:
|
|
393
|
+
"""Set the global mtime range from all entries (stable across filters)."""
|
|
394
|
+
mtimes = [e.mtime for e in self._all_entries if e.mtime > 0]
|
|
395
|
+
if mtimes:
|
|
396
|
+
tree.set_global_mtime_range(min(mtimes), max(mtimes))
|
|
397
|
+
|
|
398
|
+
def _apply_filters(self, statuses: set[GitStatus], search_query: str) -> list[FileEntry]:
|
|
399
|
+
"""Apply git status filter then fuzzy search to the full entry list."""
|
|
400
|
+
entries = filter_by_status(self._all_entries, statuses)
|
|
401
|
+
if search_query.strip():
|
|
402
|
+
entries = fuzzy_filter(entries, search_query.strip())
|
|
403
|
+
return entries
|
|
404
|
+
|
|
405
|
+
# -------------------------------------------------------------------------
|
|
406
|
+
# Keybinding actions
|
|
407
|
+
# -------------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
def action_focus_filter(self) -> None:
|
|
410
|
+
self.query_one("#search-input").focus()
|
|
411
|
+
|
|
412
|
+
def action_toggle_follow(self) -> None:
|
|
413
|
+
"""Toggle follow mode — auto-select last changed file on watch events."""
|
|
414
|
+
self._follow_mode = not self._follow_mode
|
|
415
|
+
self.notify(f"Follow mode: {'ON' if self._follow_mode else 'OFF'}")
|
|
416
|
+
|
|
417
|
+
def action_toggle_diff(self) -> None:
|
|
418
|
+
self._cycle_diff_mode(1)
|
|
419
|
+
|
|
420
|
+
def action_toggle_diff_reverse(self) -> None:
|
|
421
|
+
self._cycle_diff_mode(-1)
|
|
422
|
+
|
|
423
|
+
def _cycle_diff_mode(self, direction: int) -> None:
|
|
424
|
+
"""Cycle through diff modes, preserving scroll position by source line."""
|
|
425
|
+
modes = list(DiffMode)
|
|
426
|
+
idx = modes.index(self._diff_mode)
|
|
427
|
+
self._diff_mode = modes[(idx + direction) % len(modes)]
|
|
428
|
+
tree = self.query_one(FileTreeTable)
|
|
429
|
+
entry = tree.get_current_entry()
|
|
430
|
+
if entry and self._is_previewable(entry):
|
|
431
|
+
preview = self.query_one(PreviewPane)
|
|
432
|
+
source_line = preview.get_source_line_at_scroll()
|
|
433
|
+
preview.invalidate()
|
|
434
|
+
self._show_preview_for(entry, scroll_to_top=False, restore_line=source_line)
|
|
435
|
+
|
|
436
|
+
def action_toggle_blame(self) -> None:
|
|
437
|
+
"""Toggle blame columns (author + git time) and load data if needed."""
|
|
438
|
+
tree = self.query_one(FileTreeTable)
|
|
439
|
+
show = not tree.show_author
|
|
440
|
+
tree.show_author = show
|
|
441
|
+
tree.show_git_time = show
|
|
442
|
+
if show and self._git.is_git_repo():
|
|
443
|
+
self._load_blame_data()
|
|
444
|
+
|
|
445
|
+
def action_toggle_col(self, col: str) -> None:
|
|
446
|
+
"""Toggle a column by its reactive attribute name (e.g. 'size', 'mtime')."""
|
|
447
|
+
tree = self.query_one(FileTreeTable)
|
|
448
|
+
attr = f"show_{col}"
|
|
449
|
+
if hasattr(tree, attr):
|
|
450
|
+
setattr(tree, attr, not getattr(tree, attr))
|
|
451
|
+
|
|
452
|
+
def action_switch_pane(self) -> None:
|
|
453
|
+
"""Move focus between the file tree and the preview pane."""
|
|
454
|
+
tree = self.query_one(FileTreeTable)
|
|
455
|
+
preview = self.query_one(PreviewPane)
|
|
456
|
+
(preview if tree.has_focus else tree).focus()
|
|
457
|
+
|
|
458
|
+
def action_cycle_view(self) -> None:
|
|
459
|
+
self.query_one(FileTreeTable).action_cycle_view()
|
|
460
|
+
|
|
461
|
+
def action_toggle_modified(self) -> None:
|
|
462
|
+
self.query_one("#filter-modified").press()
|
|
463
|
+
|
|
464
|
+
# -------------------------------------------------------------------------
|
|
465
|
+
# State persistence
|
|
466
|
+
# -------------------------------------------------------------------------
|
|
467
|
+
|
|
468
|
+
def _save_state(self) -> None:
|
|
469
|
+
"""Capture all app state from live widgets and persist to disk."""
|
|
470
|
+
tree = self.query_one(FileTreeTable)
|
|
471
|
+
split = self.query_one(HorizontalSplit)
|
|
472
|
+
filter_bar = self.query_one(FilterBar)
|
|
473
|
+
entry = tree.get_current_entry()
|
|
474
|
+
|
|
475
|
+
self._state.capture_from_widgets(
|
|
476
|
+
tree,
|
|
477
|
+
filter_bar,
|
|
478
|
+
split,
|
|
479
|
+
diff_mode=self._diff_mode,
|
|
480
|
+
selected_path=entry.path if entry else None,
|
|
481
|
+
)
|
|
482
|
+
self._state.save()
|
|
483
|
+
|
|
484
|
+
def action_quit(self) -> None:
|
|
485
|
+
"""Save state and exit."""
|
|
486
|
+
self._save_state()
|
|
487
|
+
self.exit()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def main() -> None:
|
|
491
|
+
import sys
|
|
492
|
+
|
|
493
|
+
path = Path(sys.argv[1]) if len(sys.argv) > 1 else None
|
|
494
|
+
GamrApp(path=path).run()
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
if __name__ == "__main__":
|
|
498
|
+
main()
|
gamr/commands.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Command palette provider for Gamr settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.command import Hit, Hits, Provider
|
|
6
|
+
|
|
7
|
+
from gamr.widgets.file_tree_table import FileTreeTable
|
|
8
|
+
from gamr.widgets.preview_pane import DiffOverview
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GamrCommands(Provider):
|
|
12
|
+
"""Command palette commands for Gamr settings.
|
|
13
|
+
|
|
14
|
+
Textual's Provider protocol: search() yields Hit objects scored by fuzzy match.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
async def search(self, query: str) -> Hits:
|
|
18
|
+
matcher = self.matcher(query)
|
|
19
|
+
|
|
20
|
+
commands = [
|
|
21
|
+
(
|
|
22
|
+
"Toggle spaced paths (src / worker / foo.py)",
|
|
23
|
+
self._toggle_spaced_paths,
|
|
24
|
+
"Toggle spaces around / in flat path view",
|
|
25
|
+
),
|
|
26
|
+
(
|
|
27
|
+
"Toggle gradient colors on age column",
|
|
28
|
+
self._toggle_gradient,
|
|
29
|
+
"Color-code modification times by recency",
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
"Toggle diff overview style (line/braille)",
|
|
33
|
+
self._toggle_braille,
|
|
34
|
+
"Switch between line and braille overview bar",
|
|
35
|
+
),
|
|
36
|
+
]
|
|
37
|
+
for label, callback, help_text in commands:
|
|
38
|
+
score = matcher.match(label)
|
|
39
|
+
if score > 0:
|
|
40
|
+
yield Hit(score, label, callback, help=help_text)
|
|
41
|
+
|
|
42
|
+
def _toggle_spaced_paths(self) -> None:
|
|
43
|
+
tree = self.app.query_one(FileTreeTable)
|
|
44
|
+
tree.spaced_paths = not tree.spaced_paths
|
|
45
|
+
|
|
46
|
+
def _toggle_gradient(self) -> None:
|
|
47
|
+
tree = self.app.query_one(FileTreeTable)
|
|
48
|
+
tree.gradient_colors = not tree.gradient_colors
|
|
49
|
+
|
|
50
|
+
def _toggle_braille(self) -> None:
|
|
51
|
+
overview = self.app.query_one(DiffOverview)
|
|
52
|
+
overview.use_braille = not overview.use_braille
|
gamr/config.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""UI constants and configuration values.
|
|
2
|
+
|
|
3
|
+
All magic numbers and design tokens referenced by the UI are defined here
|
|
4
|
+
so they're easy to find and adjust. See docs/UI_DESIGN.md for the rationale
|
|
5
|
+
behind these values.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Gradient color ramp (256-color codes): cool → hot
|
|
9
|
+
# Applied to Size and Modified columns. See ADR-011.
|
|
10
|
+
GRADIENT_COLORS = [15, 51, 45, 39, 33, 27, 57, 93, 129, 165, 201, 200, 199, 198, 197, 196]
|
|
11
|
+
|
|
12
|
+
# Background colors for full-diff mode (hex)
|
|
13
|
+
DIFF_BG_ADDED = "#002200"
|
|
14
|
+
DIFF_BG_REMOVED = "#300000"
|
|
15
|
+
|
|
16
|
+
# Monokai theme background for the preview pane
|
|
17
|
+
PREVIEW_BG = "#272822"
|
|
18
|
+
|
|
19
|
+
# Padding width for diff backgrounds to fill the pane
|
|
20
|
+
DIFF_PAD_WIDTH = 200
|
|
21
|
+
|
|
22
|
+
# Fuzzy search score threshold (0-100). Files below this are excluded.
|
|
23
|
+
FUZZY_THRESHOLD = 50
|
|
24
|
+
|
|
25
|
+
# File watcher poll interval in seconds
|
|
26
|
+
WATCHER_POLL_INTERVAL = 0.5
|
|
27
|
+
|
|
28
|
+
# Relative timestamp refresh interval in seconds
|
|
29
|
+
TIMESTAMP_REFRESH_INTERVAL = 10
|
|
30
|
+
|
|
31
|
+
# Split handle constraints (fraction of total width)
|
|
32
|
+
SPLIT_MIN = 0.1
|
|
33
|
+
SPLIT_MAX = 0.9
|
gamr/gamr.tcss
ADDED
gamr/models.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Data models for Gamr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitStatus(Enum):
|
|
11
|
+
UNTRACKED = "?"
|
|
12
|
+
MODIFIED = "M"
|
|
13
|
+
ADDED = "A"
|
|
14
|
+
DELETED = "D"
|
|
15
|
+
STAGED_MODIFIED = "SM"
|
|
16
|
+
STAGED_ADDED = "SA"
|
|
17
|
+
STAGED_DELETED = "SD"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DiffMode(Enum):
|
|
21
|
+
"""Preview pane diff display modes."""
|
|
22
|
+
|
|
23
|
+
FULL = "full" # Full file with diff highlighting
|
|
24
|
+
GUTTER = "gutter" # File with gutter markers (plain if no changes)
|
|
25
|
+
UNIFIED = "unified" # Standard unified diff
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class FileStats:
|
|
30
|
+
lines_added: int
|
|
31
|
+
lines_removed: int
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class BlameInfo:
|
|
36
|
+
last_author: str
|
|
37
|
+
last_modified: int # unix timestamp from git author_time
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class FileEntry:
|
|
42
|
+
"""All displayable data for a single file.
|
|
43
|
+
|
|
44
|
+
Mutable fields (lines_added, last_author, etc.) are populated lazily
|
|
45
|
+
by background workers after initial build.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
path: Path
|
|
49
|
+
size: int = 0
|
|
50
|
+
mtime: float = 0.0
|
|
51
|
+
git_status: GitStatus | None = None
|
|
52
|
+
lines_added: int | None = None
|
|
53
|
+
lines_removed: int | None = None
|
|
54
|
+
last_author: str | None = None
|
|
55
|
+
last_git_modified: int | None = None # unix timestamp
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def name(self) -> str:
|
|
59
|
+
return self.path.name
|
gamr/py.typed
ADDED
|
File without changes
|