gitdirector 1.4.0__tar.gz → 1.4.2__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.
- {gitdirector-1.4.0 → gitdirector-1.4.2}/PKG-INFO +1 -1
- {gitdirector-1.4.0 → gitdirector-1.4.2}/pyproject.toml +1 -1
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/cli.py +2 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/__init__.py +20 -3
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/help.py +2 -1
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/app.py +21 -4
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/app_repos.py +36 -4
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/app_sessions.py +47 -9
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/app_ui.py +22 -1
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/constants.py +2 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/screens.py +5 -3
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/integrations/tmux/monitor.py +3 -3
- gitdirector-1.4.2/src/gitdirector/version_check.py +170 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/README.md +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/__init__.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/autoclean.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/cd.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/info.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/link.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/listt.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/pull.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/status.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/__init__.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/app_panels.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/panel_view.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/panels.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/tui/terminal_widget.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/commands/unlink.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/config.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/info.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/integrations/__init__.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/integrations/tmux/__init__.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/integrations/tmux/core.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/integrations/tmux/panels.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/manager.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/repo.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/storage.py +0 -0
- {gitdirector-1.4.0 → gitdirector-1.4.2}/src/gitdirector/ui_theme.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: gitdirector
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.2
|
|
4
4
|
Summary: A terminal based control plane for developers working across multiple repositories. Launch multiple AI coding agents, multiple tmux sessions and track changes across all your repos in one place.
|
|
5
5
|
Keywords: git,repository,manager,cli,synchronization,batch
|
|
6
6
|
Author: Anito Anto
|
|
@@ -4,7 +4,7 @@ build-backend = "uv_build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gitdirector"
|
|
7
|
-
version = "1.4.
|
|
7
|
+
version = "1.4.2"
|
|
8
8
|
description = "A terminal based control plane for developers working across multiple repositories. Launch multiple AI coding agents, multiple tmux sessions and track changes across all your repos in one place."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -14,6 +14,7 @@ from .commands import (
|
|
|
14
14
|
info,
|
|
15
15
|
link,
|
|
16
16
|
listt,
|
|
17
|
+
print_update_notice,
|
|
17
18
|
pull,
|
|
18
19
|
status,
|
|
19
20
|
tui,
|
|
@@ -39,6 +40,7 @@ class _HelpGroup(click.Group):
|
|
|
39
40
|
@click.group(cls=_HelpGroup, invoke_without_command=True)
|
|
40
41
|
@click.pass_context
|
|
41
42
|
def cli(ctx):
|
|
43
|
+
print_update_notice()
|
|
42
44
|
if ctx.invoked_subcommand is None:
|
|
43
45
|
show_help()
|
|
44
46
|
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
from typing import Optional
|
|
2
2
|
|
|
3
|
+
import click
|
|
3
4
|
from rich import box
|
|
4
5
|
from rich.console import Console
|
|
5
6
|
from rich.table import Table
|
|
6
7
|
from rich.text import Text
|
|
7
8
|
|
|
9
|
+
from .. import version_check
|
|
8
10
|
from ..repo import RepoStatus
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def _get_version() -> str:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return version("gitdirector")
|
|
14
|
+
return version_check.get_installed_version()
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
__version__: Optional[str] = None
|
|
@@ -24,6 +24,23 @@ def get_version() -> str:
|
|
|
24
24
|
return __version__
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def print_update_notice() -> None:
|
|
28
|
+
ctx = click.get_current_context(silent=True)
|
|
29
|
+
if ctx is not None:
|
|
30
|
+
root_ctx = ctx.find_root()
|
|
31
|
+
if root_ctx.meta.get("update_notice_printed"):
|
|
32
|
+
return
|
|
33
|
+
root_ctx.meta["update_notice_printed"] = True
|
|
34
|
+
|
|
35
|
+
notice = version_check.get_update_notice()
|
|
36
|
+
if notice is None:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
console.print()
|
|
40
|
+
console.print(f" [yellow]{notice}[/yellow]")
|
|
41
|
+
console.print()
|
|
42
|
+
|
|
43
|
+
|
|
27
44
|
console = Console(highlight=False)
|
|
28
45
|
|
|
29
46
|
_STATUS_COLOR = {
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import click
|
|
2
2
|
from rich.table import Table
|
|
3
3
|
|
|
4
|
-
from . import console, get_version
|
|
4
|
+
from . import console, get_version, print_update_notice
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
def show_help():
|
|
8
|
+
print_update_notice()
|
|
8
9
|
console.print()
|
|
9
10
|
console.print(
|
|
10
11
|
f" [bold white]GITDIRECTOR[/bold white] "
|
|
@@ -13,6 +13,7 @@ from textual.binding import Binding
|
|
|
13
13
|
from textual.containers import Horizontal
|
|
14
14
|
from textual.widgets import DataTable, Footer, Header, Input, Static, TabbedContent, TabPane
|
|
15
15
|
|
|
16
|
+
from ... import version_check
|
|
16
17
|
from ...manager import RepositoryManager
|
|
17
18
|
from ...repo import Repository, RepositoryInfo
|
|
18
19
|
from .. import get_version
|
|
@@ -25,6 +26,7 @@ from .constants import (
|
|
|
25
26
|
_DEFAULT_PANELS_SORT_COLUMN,
|
|
26
27
|
_DEFAULT_SESSIONS_SORT_COLUMN,
|
|
27
28
|
_DEFAULT_SORT_COLUMN,
|
|
29
|
+
_SESSION_STATUS_POLL_INTERVAL_SECS,
|
|
28
30
|
)
|
|
29
31
|
from .panels import Panel, PanelStore
|
|
30
32
|
from .screens import (
|
|
@@ -205,7 +207,10 @@ class GitDirectorConsole(
|
|
|
205
207
|
self._resume_selection_row: int | None = None
|
|
206
208
|
self._resume_new_panel_guard_until: float = 0.0
|
|
207
209
|
self._panel_store = PanelStore()
|
|
210
|
+
self._status_message = "Loading repositories…"
|
|
211
|
+
self._update_notice = version_check.get_cached_update_notice()
|
|
208
212
|
self._session_status_tracking_paused = False
|
|
213
|
+
self._session_status_tracking_running = False
|
|
209
214
|
|
|
210
215
|
def compose(self) -> ComposeResult:
|
|
211
216
|
yield Header(show_clock=True)
|
|
@@ -236,7 +241,7 @@ class GitDirectorConsole(
|
|
|
236
241
|
with Horizontal(id="search-container"):
|
|
237
242
|
yield Static("/ search:", id="search-label")
|
|
238
243
|
yield Input(placeholder="type to filter…", id="search-bar")
|
|
239
|
-
yield Static(
|
|
244
|
+
yield Static(self._compose_status_message(self._status_message), id="status-bar")
|
|
240
245
|
yield Footer()
|
|
241
246
|
|
|
242
247
|
def on_mount(self) -> None:
|
|
@@ -254,10 +259,22 @@ class GitDirectorConsole(
|
|
|
254
259
|
)
|
|
255
260
|
self.app_resume_signal.subscribe(self, self._handle_app_resume)
|
|
256
261
|
self._sync_tmux_theme_config(self.theme)
|
|
257
|
-
self._poll_timer = self.set_interval(
|
|
258
|
-
|
|
262
|
+
self._poll_timer = self.set_interval(
|
|
263
|
+
_SESSION_STATUS_POLL_INTERVAL_SECS,
|
|
264
|
+
self._trigger_status_poll,
|
|
265
|
+
)
|
|
266
|
+
self._set_session_status_tracking_running(False)
|
|
267
|
+
self._load_update_notice()
|
|
259
268
|
self._load_repos()
|
|
260
|
-
|
|
269
|
+
|
|
270
|
+
@work(thread=True)
|
|
271
|
+
def _load_update_notice(self) -> None:
|
|
272
|
+
notice = version_check.format_update_notice(version_check.get_update_status())
|
|
273
|
+
self.call_from_thread(self._set_update_notice, notice)
|
|
274
|
+
|
|
275
|
+
def _set_update_notice(self, notice: str | None) -> None:
|
|
276
|
+
self._update_notice = notice
|
|
277
|
+
self._refresh_status_bar()
|
|
261
278
|
|
|
262
279
|
def _sync_tmux_theme_config(self, theme_name: str | None = None) -> None:
|
|
263
280
|
from ...integrations.tmux import sync_panel_tmux_config
|
|
@@ -87,6 +87,13 @@ class ConsoleReposMixin:
|
|
|
87
87
|
|
|
88
88
|
def _populate_initial_rows(self) -> None:
|
|
89
89
|
table = self.query_one("#repo-table", DataTable)
|
|
90
|
+
preserved_row_key = None
|
|
91
|
+
preserved_row_index = None
|
|
92
|
+
restore_focus = False
|
|
93
|
+
if self._resume_selection_tab != "repos":
|
|
94
|
+
preserved_row_key, preserved_row_index, restore_focus = self._capture_table_selection(
|
|
95
|
+
table
|
|
96
|
+
)
|
|
90
97
|
table.clear()
|
|
91
98
|
for path in self._repo_paths:
|
|
92
99
|
table.add_row(
|
|
@@ -99,7 +106,16 @@ class ConsoleReposMixin:
|
|
|
99
106
|
str(path),
|
|
100
107
|
key=str(path),
|
|
101
108
|
)
|
|
109
|
+
|
|
110
|
+
if self._resume_selection_tab == "repos":
|
|
102
111
|
self._restore_resume_selection("repos")
|
|
112
|
+
else:
|
|
113
|
+
self._restore_table_selection(
|
|
114
|
+
table,
|
|
115
|
+
preserved_row_key,
|
|
116
|
+
preserved_row_index,
|
|
117
|
+
restore_focus=restore_focus,
|
|
118
|
+
)
|
|
103
119
|
|
|
104
120
|
def _update_row(self, info: RepositoryInfo, sessions: int = 0) -> None:
|
|
105
121
|
self._sessions_cache[str(info.path)] = sessions
|
|
@@ -138,6 +154,13 @@ class ConsoleReposMixin:
|
|
|
138
154
|
|
|
139
155
|
def _apply_filter_and_sort(self) -> None:
|
|
140
156
|
table = self.query_one("#repo-table", DataTable)
|
|
157
|
+
preserved_row_key = None
|
|
158
|
+
preserved_row_index = None
|
|
159
|
+
restore_focus = False
|
|
160
|
+
if self._resume_selection_tab != "repos":
|
|
161
|
+
preserved_row_key, preserved_row_index, restore_focus = self._capture_table_selection(
|
|
162
|
+
table
|
|
163
|
+
)
|
|
141
164
|
table.clear()
|
|
142
165
|
|
|
143
166
|
infos = list(self._results.values())
|
|
@@ -168,7 +191,15 @@ class ConsoleReposMixin:
|
|
|
168
191
|
key=str(info.path),
|
|
169
192
|
)
|
|
170
193
|
|
|
171
|
-
self.
|
|
194
|
+
if self._resume_selection_tab == "repos":
|
|
195
|
+
self._restore_resume_selection("repos")
|
|
196
|
+
else:
|
|
197
|
+
self._restore_table_selection(
|
|
198
|
+
table,
|
|
199
|
+
preserved_row_key,
|
|
200
|
+
preserved_row_index,
|
|
201
|
+
restore_focus=restore_focus,
|
|
202
|
+
)
|
|
172
203
|
self._update_status(self._build_loaded_status(len(infos), total))
|
|
173
204
|
|
|
174
205
|
def _build_loaded_status(self, shown: int, total: int) -> str:
|
|
@@ -202,10 +233,11 @@ class ConsoleReposMixin:
|
|
|
202
233
|
return msg
|
|
203
234
|
|
|
204
235
|
def action_refresh(self) -> None:
|
|
205
|
-
self._results.clear()
|
|
206
|
-
self._sessions_cache.clear()
|
|
207
|
-
self._load_repos()
|
|
208
236
|
if self._active_tab == "sessions":
|
|
209
237
|
self._load_sessions()
|
|
210
238
|
elif self._active_tab == "panels":
|
|
211
239
|
self._load_panels()
|
|
240
|
+
else:
|
|
241
|
+
self._results.clear()
|
|
242
|
+
self._sessions_cache.clear()
|
|
243
|
+
self._load_repos()
|
|
@@ -39,6 +39,13 @@ class ConsoleSessionsMixin:
|
|
|
39
39
|
table = self.query_one("#sessions-table", DataTable)
|
|
40
40
|
except NoMatches:
|
|
41
41
|
return
|
|
42
|
+
preserved_row_key = None
|
|
43
|
+
preserved_row_index = None
|
|
44
|
+
restore_focus = False
|
|
45
|
+
if self._resume_selection_tab != "sessions":
|
|
46
|
+
preserved_row_key, preserved_row_index, restore_focus = self._capture_table_selection(
|
|
47
|
+
table
|
|
48
|
+
)
|
|
42
49
|
table.clear()
|
|
43
50
|
no_msg = self.query_one("#no-sessions-message", Static)
|
|
44
51
|
|
|
@@ -86,7 +93,15 @@ class ConsoleSessionsMixin:
|
|
|
86
93
|
key=entry["session_name"],
|
|
87
94
|
)
|
|
88
95
|
|
|
89
|
-
self.
|
|
96
|
+
if self._resume_selection_tab == "sessions":
|
|
97
|
+
self._restore_resume_selection("sessions")
|
|
98
|
+
else:
|
|
99
|
+
self._restore_table_selection(
|
|
100
|
+
table,
|
|
101
|
+
preserved_row_key,
|
|
102
|
+
preserved_row_index,
|
|
103
|
+
restore_focus=restore_focus,
|
|
104
|
+
)
|
|
90
105
|
self._update_status(self._build_sessions_loaded_status(len(entries), total))
|
|
91
106
|
|
|
92
107
|
def _build_sessions_loaded_status(self, shown: int, total: int) -> str:
|
|
@@ -121,31 +136,54 @@ class ConsoleSessionsMixin:
|
|
|
121
136
|
msg += " [esc] clear search"
|
|
122
137
|
return msg
|
|
123
138
|
|
|
139
|
+
def _should_run_session_status_tracking(self) -> bool:
|
|
140
|
+
return self._active_tab == "sessions" and not self._session_status_tracking_paused
|
|
141
|
+
|
|
142
|
+
def _set_session_status_tracking_running(self, running: bool) -> None:
|
|
143
|
+
poll_timer = getattr(self, "_poll_timer", None)
|
|
144
|
+
|
|
145
|
+
if running:
|
|
146
|
+
if self._session_status_tracking_running:
|
|
147
|
+
return
|
|
148
|
+
self._monitor.start()
|
|
149
|
+
if poll_timer is not None:
|
|
150
|
+
poll_timer.resume()
|
|
151
|
+
self._session_status_tracking_running = True
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
if poll_timer is not None:
|
|
155
|
+
poll_timer.pause()
|
|
156
|
+
if self._session_status_tracking_running:
|
|
157
|
+
self._monitor.stop()
|
|
158
|
+
self._session_status_tracking_running = False
|
|
159
|
+
|
|
160
|
+
def _sync_session_status_tracking(self) -> None:
|
|
161
|
+
self._set_session_status_tracking_running(self._should_run_session_status_tracking())
|
|
162
|
+
|
|
124
163
|
def _pause_session_status_tracking(self) -> None:
|
|
125
164
|
if self._session_status_tracking_paused:
|
|
126
165
|
return
|
|
127
166
|
self._session_status_tracking_paused = True
|
|
128
|
-
|
|
129
|
-
if poll_timer is not None:
|
|
130
|
-
poll_timer.pause()
|
|
131
|
-
self._monitor.stop()
|
|
167
|
+
self._set_session_status_tracking_running(False)
|
|
132
168
|
|
|
133
169
|
def _resume_session_status_tracking(self) -> None:
|
|
134
170
|
if not self._session_status_tracking_paused:
|
|
135
171
|
return
|
|
136
172
|
self._session_status_tracking_paused = False
|
|
137
|
-
self.
|
|
138
|
-
poll_timer = getattr(self, "_poll_timer", None)
|
|
139
|
-
if poll_timer is not None:
|
|
140
|
-
poll_timer.resume()
|
|
173
|
+
self._sync_session_status_tracking()
|
|
141
174
|
|
|
142
175
|
def _trigger_status_poll(self) -> None:
|
|
176
|
+
if not self._should_run_session_status_tracking():
|
|
177
|
+
return
|
|
143
178
|
self._poll_session_statuses()
|
|
144
179
|
|
|
145
180
|
@work(thread=True, exclusive=True, group="status_poll")
|
|
146
181
|
def _poll_session_statuses(self) -> None:
|
|
147
182
|
from ...integrations.tmux import get_all_session_statuses, list_all_gd_sessions
|
|
148
183
|
|
|
184
|
+
if not self._should_run_session_status_tracking():
|
|
185
|
+
return
|
|
186
|
+
|
|
149
187
|
entries = list_all_gd_sessions()
|
|
150
188
|
statuses = get_all_session_statuses()
|
|
151
189
|
self._session_statuses = statuses
|
|
@@ -24,6 +24,22 @@ logger = logging.getLogger(__name__)
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class ConsoleUIHelpersMixin:
|
|
27
|
+
def _compose_status_message(self, message: str) -> str:
|
|
28
|
+
notice = getattr(self, "_update_notice", None)
|
|
29
|
+
if not notice:
|
|
30
|
+
return message
|
|
31
|
+
if not message:
|
|
32
|
+
return notice
|
|
33
|
+
return f"{message} | {notice}"
|
|
34
|
+
|
|
35
|
+
def _refresh_status_bar(self) -> None:
|
|
36
|
+
try:
|
|
37
|
+
self.query_one("#status-bar", Static).update(
|
|
38
|
+
self._compose_status_message(self._status_message)
|
|
39
|
+
)
|
|
40
|
+
except NoMatches:
|
|
41
|
+
return
|
|
42
|
+
|
|
27
43
|
def action_tab_repos(self) -> None:
|
|
28
44
|
if self._resume_target_tab is not None and self._resume_target_tab != "repos":
|
|
29
45
|
return
|
|
@@ -66,6 +82,7 @@ class ConsoleUIHelpersMixin:
|
|
|
66
82
|
self._resume_tab_activation_guard = None
|
|
67
83
|
self._active_tab = tab_id
|
|
68
84
|
self.refresh_bindings()
|
|
85
|
+
self._sync_session_status_tracking()
|
|
69
86
|
return
|
|
70
87
|
if self._resume_target_tab is not None:
|
|
71
88
|
if tab_id != self._resume_target_tab:
|
|
@@ -73,9 +90,11 @@ class ConsoleUIHelpersMixin:
|
|
|
73
90
|
return
|
|
74
91
|
self._active_tab = tab_id
|
|
75
92
|
self.refresh_bindings()
|
|
93
|
+
self._sync_session_status_tracking()
|
|
76
94
|
return
|
|
77
95
|
self._active_tab = tab_id
|
|
78
96
|
self.refresh_bindings()
|
|
97
|
+
self._sync_session_status_tracking()
|
|
79
98
|
if tab_id == "sessions":
|
|
80
99
|
self._load_sessions()
|
|
81
100
|
elif tab_id == "panels":
|
|
@@ -123,6 +142,7 @@ class ConsoleUIHelpersMixin:
|
|
|
123
142
|
tabs.active = restore_tab
|
|
124
143
|
self._active_tab = restore_tab
|
|
125
144
|
self.refresh_bindings()
|
|
145
|
+
self._sync_session_status_tracking()
|
|
126
146
|
|
|
127
147
|
if restore_tab == "sessions":
|
|
128
148
|
self._load_sessions()
|
|
@@ -144,7 +164,8 @@ class ConsoleUIHelpersMixin:
|
|
|
144
164
|
self._refresh_repo_for_path(restore_path)
|
|
145
165
|
|
|
146
166
|
def _update_status(self, message: str) -> None:
|
|
147
|
-
self.
|
|
167
|
+
self._status_message = message
|
|
168
|
+
self._refresh_status_bar()
|
|
148
169
|
|
|
149
170
|
def _update_search_indicator(self) -> None:
|
|
150
171
|
repo_ind = self.query_one("#repo-search-indicator", Static)
|
|
@@ -98,7 +98,9 @@ class ActionMenuScreen(ModalScreen[str]):
|
|
|
98
98
|
items.extend(
|
|
99
99
|
[
|
|
100
100
|
Option("", disabled=True),
|
|
101
|
-
Option(
|
|
101
|
+
Option(
|
|
102
|
+
"[white]✕[/white] [dim]Remove Session...[/dim]", id="remove_session"
|
|
103
|
+
),
|
|
102
104
|
]
|
|
103
105
|
)
|
|
104
106
|
yield OptionList(*items, id="action-menu")
|
|
@@ -537,7 +539,7 @@ class PullLoadingScreen(ModalScreen[None]):
|
|
|
537
539
|
yield LoadingIndicator()
|
|
538
540
|
yield Static(f"Pulling [bold]{escape(self.repo_name)}[/bold]", id="pull-loading-title")
|
|
539
541
|
yield Static(f"[dim]{escape(self.command)}[/dim]", id="pull-loading-command")
|
|
540
|
-
yield Static("please wait
|
|
542
|
+
yield Static("please wait...", id="pull-loading-hint")
|
|
541
543
|
|
|
542
544
|
|
|
543
545
|
class SortMenuScreen(ModalScreen[tuple | None]):
|
|
@@ -998,7 +1000,7 @@ class CreatePanelScreen(ModalScreen[tuple[str, str, dict[int, str | None]] | Non
|
|
|
998
1000
|
)
|
|
999
1001
|
else:
|
|
1000
1002
|
yield Static("[dim]Name[/dim]", id="panel-name-label")
|
|
1001
|
-
yield Input(placeholder="panel name
|
|
1003
|
+
yield Input(placeholder="panel name...", id="panel-name-input")
|
|
1002
1004
|
with Horizontal(id="step-1-columns"):
|
|
1003
1005
|
with Vertical(id="step-1-left"):
|
|
1004
1006
|
yield Static("[dim]Layout[/dim]", classes="section-label")
|
|
@@ -69,9 +69,9 @@ _SHELL_COMMANDS = frozenset(
|
|
|
69
69
|
|
|
70
70
|
_AGENT_PURPOSES = frozenset({"opencode", "claude", "copilot", "codex"})
|
|
71
71
|
|
|
72
|
-
_SILENCE_THRESHOLD_SECS =
|
|
72
|
+
_SILENCE_THRESHOLD_SECS = 11
|
|
73
73
|
_BELL_GRACE_SECS = 1.0
|
|
74
|
-
_CONTENT_POLL_SECS =
|
|
74
|
+
_CONTENT_POLL_SECS = 10
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
def _normalize_process_command(raw_args: str) -> str:
|
|
@@ -440,7 +440,7 @@ class TmuxMonitor:
|
|
|
440
440
|
except Exception:
|
|
441
441
|
pass
|
|
442
442
|
|
|
443
|
-
for _ in range(
|
|
443
|
+
for _ in range(_CONTENT_POLL_SECS * 10):
|
|
444
444
|
if not self._running:
|
|
445
445
|
return
|
|
446
446
|
time.sleep(0.1)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""PyPI release checking with a short local cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.request import urlopen
|
|
12
|
+
|
|
13
|
+
from .storage import advisory_file_lock, load_yaml_mapping, write_yaml_atomic
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from packaging.version import InvalidVersion, Version
|
|
17
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
18
|
+
InvalidVersion = ValueError
|
|
19
|
+
Version = None
|
|
20
|
+
|
|
21
|
+
_PACKAGE_NAME = "gitdirector"
|
|
22
|
+
_PYPI_JSON_URL = f"https://pypi.org/pypi/{_PACKAGE_NAME}/json"
|
|
23
|
+
_VERSION_CACHE_TTL = timedelta(hours=6)
|
|
24
|
+
_VERSION_CHECK_TIMEOUT_SECS = 1.0
|
|
25
|
+
_VERSION_RE = re.compile(r"^v?(?P<release>\d+(?:\.\d+)*)(?P<suffix>.*)$", re.IGNORECASE)
|
|
26
|
+
_SUFFIX_RANK = {
|
|
27
|
+
"dev": 0,
|
|
28
|
+
"a": 1,
|
|
29
|
+
"alpha": 1,
|
|
30
|
+
"b": 2,
|
|
31
|
+
"beta": 2,
|
|
32
|
+
"rc": 3,
|
|
33
|
+
"c": 3,
|
|
34
|
+
"": 4,
|
|
35
|
+
"post": 5,
|
|
36
|
+
"rev": 5,
|
|
37
|
+
"r": 5,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class UpdateStatus:
|
|
43
|
+
current_version: str
|
|
44
|
+
latest_version: str | None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def update_available(self) -> bool:
|
|
48
|
+
if not self.latest_version:
|
|
49
|
+
return False
|
|
50
|
+
return _is_version_newer(self.latest_version, self.current_version)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _utcnow() -> datetime:
|
|
54
|
+
return datetime.now(timezone.utc)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cache_paths() -> tuple[Path, Path]:
|
|
58
|
+
cache_dir = Path.home() / ".gitdirector"
|
|
59
|
+
return cache_dir / "version_check.yaml", cache_dir / "version_check.lock"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_checked_at(raw_value: object) -> datetime | None:
|
|
63
|
+
if not isinstance(raw_value, str) or not raw_value.strip():
|
|
64
|
+
return None
|
|
65
|
+
try:
|
|
66
|
+
parsed = datetime.fromisoformat(raw_value)
|
|
67
|
+
except ValueError:
|
|
68
|
+
return None
|
|
69
|
+
if parsed.tzinfo is None:
|
|
70
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
71
|
+
return parsed.astimezone(timezone.utc)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read_cache() -> tuple[datetime | None, str | None]:
|
|
75
|
+
cache_path, lock_path = _cache_paths()
|
|
76
|
+
with advisory_file_lock(lock_path):
|
|
77
|
+
data = load_yaml_mapping(cache_path, description="GitDirector version cache")
|
|
78
|
+
checked_at = _parse_checked_at(data.get("checked_at"))
|
|
79
|
+
latest_version = data.get("latest_version")
|
|
80
|
+
if not isinstance(latest_version, str) or not latest_version.strip():
|
|
81
|
+
latest_version = None
|
|
82
|
+
return checked_at, latest_version
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _write_cache(checked_at: datetime, latest_version: str | None) -> None:
|
|
86
|
+
cache_path, lock_path = _cache_paths()
|
|
87
|
+
data: dict[str, object] = {"checked_at": checked_at.isoformat()}
|
|
88
|
+
if latest_version:
|
|
89
|
+
data["latest_version"] = latest_version
|
|
90
|
+
with advisory_file_lock(lock_path):
|
|
91
|
+
write_yaml_atomic(cache_path, data)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _fetch_latest_version() -> str | None:
|
|
95
|
+
with urlopen(_PYPI_JSON_URL, timeout=_VERSION_CHECK_TIMEOUT_SECS) as response:
|
|
96
|
+
payload = json.load(response)
|
|
97
|
+
latest_version = payload.get("info", {}).get("version")
|
|
98
|
+
if not isinstance(latest_version, str) or not latest_version.strip():
|
|
99
|
+
return None
|
|
100
|
+
return latest_version.strip()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _fallback_version_key(version: str) -> tuple[tuple[int, ...], int, tuple[int, ...]]:
|
|
104
|
+
normalized = version.strip().lower()
|
|
105
|
+
match = _VERSION_RE.match(normalized)
|
|
106
|
+
if match is None:
|
|
107
|
+
return (0,), 0, ()
|
|
108
|
+
|
|
109
|
+
release = tuple(int(part) for part in match.group("release").split("."))
|
|
110
|
+
suffix = match.group("suffix").strip(".-+_").lower()
|
|
111
|
+
if not suffix:
|
|
112
|
+
return release, _SUFFIX_RANK[""], ()
|
|
113
|
+
|
|
114
|
+
tokens = re.findall(r"[a-z]+|\d+", suffix)
|
|
115
|
+
label = next((token for token in tokens if token.isalpha()), "")
|
|
116
|
+
numbers = tuple(int(token) for token in tokens if token.isdigit())
|
|
117
|
+
return release, _SUFFIX_RANK.get(label, 0), numbers
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_version_newer(latest_version: str, current_version: str) -> bool:
|
|
121
|
+
if Version is not None:
|
|
122
|
+
try:
|
|
123
|
+
return Version(latest_version) > Version(current_version)
|
|
124
|
+
except InvalidVersion:
|
|
125
|
+
pass
|
|
126
|
+
return _fallback_version_key(latest_version) > _fallback_version_key(current_version)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@lru_cache(maxsize=1)
|
|
130
|
+
def get_installed_version() -> str:
|
|
131
|
+
from importlib.metadata import version
|
|
132
|
+
|
|
133
|
+
return version(_PACKAGE_NAME)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_cached_update_status() -> UpdateStatus | None:
|
|
137
|
+
_, latest_version = _read_cache()
|
|
138
|
+
return UpdateStatus(get_installed_version(), latest_version)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_update_status() -> UpdateStatus | None:
|
|
142
|
+
current_version = get_installed_version()
|
|
143
|
+
now = _utcnow()
|
|
144
|
+
checked_at, cached_latest_version = _read_cache()
|
|
145
|
+
|
|
146
|
+
if checked_at is not None and now - checked_at <= _VERSION_CACHE_TTL:
|
|
147
|
+
return UpdateStatus(current_version, cached_latest_version)
|
|
148
|
+
|
|
149
|
+
latest_version = cached_latest_version
|
|
150
|
+
try:
|
|
151
|
+
latest_version = _fetch_latest_version()
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
_write_cache(now, latest_version)
|
|
156
|
+
return UpdateStatus(current_version, latest_version)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def format_update_notice(status: UpdateStatus | None) -> str | None:
|
|
160
|
+
if status is None or not status.update_available or not status.latest_version:
|
|
161
|
+
return None
|
|
162
|
+
return f"Update available: v{status.latest_version} (current v{status.current_version})"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_cached_update_notice() -> str | None:
|
|
166
|
+
return format_update_notice(get_cached_update_status())
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_update_notice() -> str | None:
|
|
170
|
+
return format_update_notice(get_update_status())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|