gitpulse-tui 1.2.7__tar.gz → 1.2.9__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.
- {gitpulse_tui-1.2.7/gitpulse_tui.egg-info → gitpulse_tui-1.2.9}/PKG-INFO +1 -1
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/README.md +19 -1
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/git_ops.py +8 -8
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/main.py +41 -2
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/bulk_results.py +10 -10
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/command_palette.py +6 -6
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/digest_screen.py +24 -24
- gitpulse_tui-1.2.9/gitpulse/ui/fleet_status.py +100 -0
- gitpulse_tui-1.2.9/gitpulse/ui/header.py +84 -0
- gitpulse_tui-1.2.9/gitpulse/ui/help_modal.py +101 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/sidebar.py +82 -115
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/stale_screen.py +20 -20
- gitpulse_tui-1.2.9/gitpulse/ui/styles.tcss +569 -0
- gitpulse_tui-1.2.9/gitpulse/ui/summary_cards.py +93 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/tabs.py +278 -222
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/utils.py +1 -1
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9/gitpulse_tui.egg-info}/PKG-INFO +1 -1
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse_tui.egg-info/SOURCES.txt +3 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/pyproject.toml +1 -1
- gitpulse_tui-1.2.7/gitpulse/ui/fleet_status.py +0 -138
- gitpulse_tui-1.2.7/gitpulse/ui/styles.tcss +0 -459
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/LICENSE +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/__init__.py +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/__main__.py +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/config.py +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/digest.py +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/parallel.py +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/scanner.py +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/stale.py +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/__init__.py +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/watcher.py +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse_tui.egg-info/dependency_links.txt +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse_tui.egg-info/entry_points.txt +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse_tui.egg-info/requires.txt +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse_tui.egg-info/top_level.txt +0 -0
- {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/setup.cfg +0 -0
|
@@ -4,8 +4,25 @@ A developer-focused terminal dashboard that scans a root directory for all local
|
|
|
4
4
|
|
|
5
5
|
Built with **Python**, **Textual**, **Rich**, and **GitPython**.
|
|
6
6
|
|
|
7
|
+
## Why GitPulse?
|
|
8
|
+
|
|
9
|
+
A lot of development happens late at night, and not everyone commits or pushes
|
|
10
|
+
their changes right away. With "vibe coding" becoming so popular, many people
|
|
11
|
+
now juggle several projects at once — and that makes it genuinely hard to keep
|
|
12
|
+
track of every local repository.
|
|
13
|
+
|
|
14
|
+
GitPulse exists to solve exactly that. It gives you a single dashboard for all
|
|
15
|
+
your local repos, so at a glance you can see what's committed but not yet
|
|
16
|
+
pushed, what's changed but not yet committed, the number of modified files, the
|
|
17
|
+
current branch, the last commit, and everything else about the state of each
|
|
18
|
+
local Git repository.
|
|
19
|
+
|
|
7
20
|
## Screenshots
|
|
8
21
|
|
|
22
|
+
**Demo** — GitPulse in action
|
|
23
|
+
|
|
24
|
+

|
|
25
|
+
|
|
9
26
|
**📋 Status tab** — repo summary panel, staged/unstaged/untracked file lists, stash entries
|
|
10
27
|
|
|
11
28
|

|
|
@@ -30,9 +47,10 @@ launcher — it bootstraps an isolated Python environment and runs the same
|
|
|
30
47
|
Python application (no logic is duplicated):
|
|
31
48
|
|
|
32
49
|
```bash
|
|
33
|
-
npm install -g gitpulse
|
|
50
|
+
npm install -g gitpulse-tui
|
|
34
51
|
```
|
|
35
52
|
|
|
53
|
+
The CLI command is still `gitpulse` once installed.
|
|
36
54
|
This requires Python 3.10+ on your system; the wrapper handles the rest.
|
|
37
55
|
See [npm/README.md](./npm/README.md) for details and troubleshooting.
|
|
38
56
|
|
|
@@ -725,8 +725,8 @@ def get_file_tree(path: Path) -> "rich.tree.Tree":
|
|
|
725
725
|
# Render the dict structure into a Rich Tree
|
|
726
726
|
repo_name = path.name
|
|
727
727
|
rich_tree = RichTree(
|
|
728
|
-
f"[bold #
|
|
729
|
-
guide_style="#
|
|
728
|
+
f"[bold #8b5cf6]{repo_name}[/]",
|
|
729
|
+
guide_style="#1f2937",
|
|
730
730
|
)
|
|
731
731
|
|
|
732
732
|
def build_tree(node: "RichTree", d: dict) -> None:
|
|
@@ -735,22 +735,22 @@ def get_file_tree(path: Path) -> "rich.tree.Tree":
|
|
|
735
735
|
files = sorted(k for k, v in d.items() if v is None)
|
|
736
736
|
|
|
737
737
|
for name in dirs:
|
|
738
|
-
branch = node.add(f"[bold #
|
|
738
|
+
branch = node.add(f"[bold #8b5cf6]{name}/[/]")
|
|
739
739
|
build_tree(branch, d[name])
|
|
740
740
|
|
|
741
741
|
for name in files:
|
|
742
742
|
# Color-code by extension
|
|
743
743
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
744
744
|
if ext in ("py", "js", "ts", "go", "rs", "c", "cpp", "java"):
|
|
745
|
-
label = f"[#
|
|
745
|
+
label = f"[#22c55e]{name}[/]"
|
|
746
746
|
elif ext in ("md", "rst", "txt"):
|
|
747
|
-
label = f"[#
|
|
747
|
+
label = f"[#f59e0b]{name}[/]"
|
|
748
748
|
elif ext in ("json", "yaml", "yml", "toml", "ini", "cfg", "env"):
|
|
749
|
-
label = f"[#
|
|
749
|
+
label = f"[#8b5cf6]{name}[/]"
|
|
750
750
|
elif ext in ("sh", "bash", "zsh"):
|
|
751
|
-
label = f"[#
|
|
751
|
+
label = f"[#ef4444]{name}[/]"
|
|
752
752
|
else:
|
|
753
|
-
label = f"[#
|
|
753
|
+
label = f"[#d1d5db]{name}[/]"
|
|
754
754
|
node.add(label)
|
|
755
755
|
|
|
756
756
|
build_tree(rich_tree, root_dict)
|
|
@@ -16,7 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
|
|
17
17
|
from textual.app import App, ComposeResult
|
|
18
18
|
from textual.binding import Binding
|
|
19
|
-
from textual.widgets import
|
|
19
|
+
from textual.widgets import Footer, Input
|
|
20
20
|
from textual.containers import Horizontal, Vertical
|
|
21
21
|
from textual.worker import Worker, WorkerState
|
|
22
22
|
|
|
@@ -27,6 +27,8 @@ try:
|
|
|
27
27
|
from gitpulse.git_ops import get_repo_info, switch_branch, RepoInfo
|
|
28
28
|
from gitpulse.ui.sidebar import RepoSidebar
|
|
29
29
|
from gitpulse.ui.tabs import MainPanel
|
|
30
|
+
from gitpulse.ui.header import AppHeader, TAB_IDS
|
|
31
|
+
from gitpulse.ui.help_modal import HelpModal
|
|
30
32
|
from gitpulse.ui.fleet_status import FleetStatus
|
|
31
33
|
from gitpulse.ui.digest_screen import DigestScreen
|
|
32
34
|
from gitpulse.ui.command_palette import CommandPaletteModal
|
|
@@ -44,6 +46,8 @@ except ImportError:
|
|
|
44
46
|
from git_ops import get_repo_info, switch_branch, RepoInfo # type: ignore[no-redef]
|
|
45
47
|
from ui.sidebar import RepoSidebar # type: ignore[no-redef]
|
|
46
48
|
from ui.tabs import MainPanel # type: ignore[no-redef]
|
|
49
|
+
from ui.header import AppHeader, TAB_IDS # type: ignore[no-redef]
|
|
50
|
+
from ui.help_modal import HelpModal # type: ignore[no-redef]
|
|
47
51
|
from ui.fleet_status import FleetStatus # type: ignore[no-redef]
|
|
48
52
|
from ui.digest_screen import DigestScreen # type: ignore[no-redef]
|
|
49
53
|
from ui.command_palette import CommandPaletteModal # type: ignore[no-redef]
|
|
@@ -79,6 +83,9 @@ class GitPulseApp(App):
|
|
|
79
83
|
Binding("escape", "clear_search", "Clear", show=False),
|
|
80
84
|
Binding("tab", "focus_next", "Next", show=False),
|
|
81
85
|
Binding("shift+tab", "focus_previous", "Prev", show=False),
|
|
86
|
+
Binding("right_square_bracket", "next_tab", "Next Tab", show=False),
|
|
87
|
+
Binding("left_square_bracket", "prev_tab", "Prev Tab", show=False),
|
|
88
|
+
Binding("question_mark", "open_help", "Help", show=True),
|
|
82
89
|
]
|
|
83
90
|
|
|
84
91
|
def __init__(
|
|
@@ -105,7 +112,7 @@ class GitPulseApp(App):
|
|
|
105
112
|
# -----------------------------------------------------------------
|
|
106
113
|
|
|
107
114
|
def compose(self) -> ComposeResult:
|
|
108
|
-
yield
|
|
115
|
+
yield AppHeader(id="app-header")
|
|
109
116
|
with Horizontal(id="app-grid"):
|
|
110
117
|
with Vertical(id="sidebar-column"):
|
|
111
118
|
yield FleetStatus(id="fleet-status")
|
|
@@ -250,6 +257,38 @@ class GitPulseApp(App):
|
|
|
250
257
|
sidebar: RepoSidebar = self.query_one("#sidebar-container", RepoSidebar)
|
|
251
258
|
sidebar.focus_search()
|
|
252
259
|
|
|
260
|
+
def action_next_tab(self) -> None:
|
|
261
|
+
"""Switch to the next content tab (bound to ']')."""
|
|
262
|
+
self._cycle_tab(1)
|
|
263
|
+
|
|
264
|
+
def action_prev_tab(self) -> None:
|
|
265
|
+
"""Switch to the previous content tab (bound to '[')."""
|
|
266
|
+
self._cycle_tab(-1)
|
|
267
|
+
|
|
268
|
+
def _cycle_tab(self, delta: int) -> None:
|
|
269
|
+
main: MainPanel = self.query_one("#main-panel", MainPanel)
|
|
270
|
+
current = main._active_tab()
|
|
271
|
+
try:
|
|
272
|
+
idx = TAB_IDS.index(current)
|
|
273
|
+
except ValueError:
|
|
274
|
+
idx = 0
|
|
275
|
+
self._switch_tab(TAB_IDS[(idx + delta) % len(TAB_IDS)])
|
|
276
|
+
|
|
277
|
+
def _switch_tab(self, tab_id: str) -> None:
|
|
278
|
+
"""Switch the active content tab and sync the header highlight."""
|
|
279
|
+
header: AppHeader = self.query_one("#app-header", AppHeader)
|
|
280
|
+
header.set_active(tab_id)
|
|
281
|
+
main: MainPanel = self.query_one("#main-panel", MainPanel)
|
|
282
|
+
main.show_tab(tab_id)
|
|
283
|
+
|
|
284
|
+
def on_app_header_tab_changed(self, message: AppHeader.TabChanged) -> None:
|
|
285
|
+
"""User clicked a tab in the header."""
|
|
286
|
+
self._switch_tab(message.tab_id)
|
|
287
|
+
|
|
288
|
+
def action_open_help(self) -> None:
|
|
289
|
+
"""Show the keyboard-shortcut cheat sheet (bound to '?')."""
|
|
290
|
+
self.push_screen(HelpModal())
|
|
291
|
+
|
|
253
292
|
def action_clear_search(self) -> None:
|
|
254
293
|
"""Clear search and refocus repo list."""
|
|
255
294
|
inp = self.query_one("#search-input", Input)
|
|
@@ -30,14 +30,14 @@ class BulkResultsScreen(ModalScreen):
|
|
|
30
30
|
#results-frame {
|
|
31
31
|
width: 88%;
|
|
32
32
|
height: 75%;
|
|
33
|
-
background: #
|
|
34
|
-
border:
|
|
33
|
+
background: #111827;
|
|
34
|
+
border: solid #8b5cf6;
|
|
35
35
|
}
|
|
36
36
|
#results-title {
|
|
37
37
|
dock: top;
|
|
38
38
|
height: 1;
|
|
39
|
-
background: #
|
|
40
|
-
color: #
|
|
39
|
+
background: #1f2937;
|
|
40
|
+
color: #8b5cf6;
|
|
41
41
|
text-style: bold;
|
|
42
42
|
padding: 0 1;
|
|
43
43
|
}
|
|
@@ -48,10 +48,10 @@ class BulkResultsScreen(ModalScreen):
|
|
|
48
48
|
#results-footer {
|
|
49
49
|
dock: bottom;
|
|
50
50
|
height: 1;
|
|
51
|
-
background: #
|
|
52
|
-
color: #
|
|
51
|
+
background: #111827;
|
|
52
|
+
color: #6b7280;
|
|
53
53
|
padding: 0 1;
|
|
54
|
-
border-top: solid #
|
|
54
|
+
border-top: solid #1f2937;
|
|
55
55
|
}
|
|
56
56
|
"""
|
|
57
57
|
|
|
@@ -87,13 +87,13 @@ class BulkResultsScreen(ModalScreen):
|
|
|
87
87
|
self._completed += 1
|
|
88
88
|
|
|
89
89
|
if isinstance(result, Exception):
|
|
90
|
-
status = "[bold #
|
|
90
|
+
status = "[bold #ef4444]ERROR[/]"
|
|
91
91
|
output = str(result)[:80]
|
|
92
92
|
elif isinstance(result, str) and result.lower().startswith("error"):
|
|
93
|
-
status = "[bold #
|
|
93
|
+
status = "[bold #ef4444]FAIL[/]"
|
|
94
94
|
output = result[:80]
|
|
95
95
|
else:
|
|
96
|
-
status = "[bold #
|
|
96
|
+
status = "[bold #22c55e]OK[/]"
|
|
97
97
|
output = (str(result) if result else "done")[:80]
|
|
98
98
|
|
|
99
99
|
table.add_row(repo.name, status, output)
|
|
@@ -39,19 +39,19 @@ class CommandPaletteModal(ModalScreen):
|
|
|
39
39
|
height: auto;
|
|
40
40
|
max-height: 24;
|
|
41
41
|
padding: 1 2;
|
|
42
|
-
background: #
|
|
43
|
-
border:
|
|
42
|
+
background: #111827;
|
|
43
|
+
border: solid #8b5cf6;
|
|
44
44
|
}
|
|
45
45
|
#palette-title {
|
|
46
46
|
text-style: bold;
|
|
47
|
-
color: #
|
|
47
|
+
color: #8b5cf6;
|
|
48
48
|
margin-bottom: 1;
|
|
49
49
|
text-align: center;
|
|
50
50
|
width: 100%;
|
|
51
51
|
height: 1;
|
|
52
52
|
}
|
|
53
53
|
#palette-scope {
|
|
54
|
-
color: #
|
|
54
|
+
color: #f59e0b;
|
|
55
55
|
margin-bottom: 1;
|
|
56
56
|
width: 100%;
|
|
57
57
|
height: 1;
|
|
@@ -79,7 +79,7 @@ class CommandPaletteModal(ModalScreen):
|
|
|
79
79
|
else "current repo"
|
|
80
80
|
)
|
|
81
81
|
with Container(id="palette-frame"):
|
|
82
|
-
yield Static("
|
|
82
|
+
yield Static("Bulk Action", id="palette-title", markup=False)
|
|
83
83
|
yield Static(f" Scope: {scope_text}", id="palette-scope", markup=False)
|
|
84
84
|
yield Input(placeholder="Filter actions…", id="palette-input")
|
|
85
85
|
yield ListView(id="palette-list")
|
|
@@ -93,7 +93,7 @@ class CommandPaletteModal(ModalScreen):
|
|
|
93
93
|
lv.clear()
|
|
94
94
|
for key, label, desc in actions:
|
|
95
95
|
lv.append(ListItem(
|
|
96
|
-
Static(f"[bold #
|
|
96
|
+
Static(f"[bold #22c55e]{label}[/] [dim #6b7280]{desc}[/]", markup=True),
|
|
97
97
|
id=f"action-{key}",
|
|
98
98
|
))
|
|
99
99
|
if actions:
|
|
@@ -44,23 +44,23 @@ class DigestScreen(ModalScreen):
|
|
|
44
44
|
#digest-frame {
|
|
45
45
|
width: 95%;
|
|
46
46
|
height: 90%;
|
|
47
|
-
background: #
|
|
48
|
-
border:
|
|
47
|
+
background: #111827;
|
|
48
|
+
border: solid #8b5cf6;
|
|
49
49
|
}
|
|
50
50
|
#digest-header {
|
|
51
51
|
dock: top;
|
|
52
52
|
height: 3;
|
|
53
|
-
background: #
|
|
54
|
-
color: #
|
|
53
|
+
background: #1f2937;
|
|
54
|
+
color: #8b5cf6;
|
|
55
55
|
text-style: bold;
|
|
56
56
|
padding: 0 2;
|
|
57
|
-
border-bottom: heavy #
|
|
57
|
+
border-bottom: heavy #1f2937;
|
|
58
58
|
layout: horizontal;
|
|
59
59
|
align: left middle;
|
|
60
60
|
}
|
|
61
61
|
#digest-window-label {
|
|
62
62
|
width: auto;
|
|
63
|
-
color: #
|
|
63
|
+
color: #8b5cf6;
|
|
64
64
|
margin-left: 2;
|
|
65
65
|
}
|
|
66
66
|
#digest-scroll {
|
|
@@ -74,10 +74,10 @@ class DigestScreen(ModalScreen):
|
|
|
74
74
|
#digest-footer {
|
|
75
75
|
dock: bottom;
|
|
76
76
|
height: 1;
|
|
77
|
-
background: #
|
|
78
|
-
color: #
|
|
77
|
+
background: #111827;
|
|
78
|
+
color: #6b7280;
|
|
79
79
|
padding: 0 1;
|
|
80
|
-
border-top: solid #
|
|
80
|
+
border-top: solid #1f2937;
|
|
81
81
|
}
|
|
82
82
|
"""
|
|
83
83
|
|
|
@@ -97,7 +97,7 @@ class DigestScreen(ModalScreen):
|
|
|
97
97
|
def compose(self) -> ComposeResult:
|
|
98
98
|
with Container(id="digest-frame"):
|
|
99
99
|
with Horizontal(id="digest-header"):
|
|
100
|
-
yield Static("
|
|
100
|
+
yield Static("Activity Digest", markup=False)
|
|
101
101
|
yield Static("", id="digest-window-label")
|
|
102
102
|
with ScrollableContainer(id="digest-scroll"):
|
|
103
103
|
yield Static(
|
|
@@ -116,7 +116,7 @@ class DigestScreen(ModalScreen):
|
|
|
116
116
|
|
|
117
117
|
def _load_digest(self) -> None:
|
|
118
118
|
label: Static = self.query_one("#digest-window-label", Static)
|
|
119
|
-
label.update(f"[#
|
|
119
|
+
label.update(f"[#8b5cf6]window: {self._window}[/]")
|
|
120
120
|
|
|
121
121
|
body: Static = self.query_one("#digest-body", Static)
|
|
122
122
|
body.update("[dim italic]Computing digest…[/]")
|
|
@@ -124,7 +124,7 @@ class DigestScreen(ModalScreen):
|
|
|
124
124
|
try:
|
|
125
125
|
since_ts = parse_since(self._window)
|
|
126
126
|
except ValueError as e:
|
|
127
|
-
body.update(f"[bold #
|
|
127
|
+
body.update(f"[bold #ef4444]Error: {e}[/]")
|
|
128
128
|
return
|
|
129
129
|
|
|
130
130
|
# Run in a worker so we don't block the UI.
|
|
@@ -144,7 +144,7 @@ class DigestScreen(ModalScreen):
|
|
|
144
144
|
self._render_digest()
|
|
145
145
|
elif event.state == WorkerState.ERROR:
|
|
146
146
|
body: Static = self.query_one("#digest-body", Static)
|
|
147
|
-
body.update(f"[bold #
|
|
147
|
+
body.update(f"[bold #ef4444]Error building digest: {event.worker.error}[/]")
|
|
148
148
|
|
|
149
149
|
def _render_digest(self) -> None:
|
|
150
150
|
d = self._digest
|
|
@@ -156,34 +156,34 @@ class DigestScreen(ModalScreen):
|
|
|
156
156
|
if d.total_commits == 0:
|
|
157
157
|
body.update(
|
|
158
158
|
"[dim italic] No commits found for this window and author pattern.[/]\n"
|
|
159
|
-
"[dim #
|
|
159
|
+
"[dim #6b7280] Tip: configure author emails in ~/.config/gitpulse/config.toml[/]"
|
|
160
160
|
)
|
|
161
161
|
return
|
|
162
162
|
|
|
163
163
|
lines: list[str] = []
|
|
164
164
|
since_str = datetime.fromtimestamp(d.since_ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
|
165
165
|
lines.append(
|
|
166
|
-
f"[bold #
|
|
167
|
-
f"[bold #
|
|
168
|
-
f"[#
|
|
166
|
+
f"[bold #8b5cf6]{d.total_commits}[/] commits across "
|
|
167
|
+
f"[bold #22c55e]{d.repos_active}[/] repos "
|
|
168
|
+
f"[#22c55e]+{d.total_insertions}[/] [#ef4444]-{d.total_deletions}[/] lines "
|
|
169
169
|
f"[dim]since {since_str} UTC[/]"
|
|
170
170
|
)
|
|
171
|
-
lines.append("[dim #
|
|
171
|
+
lines.append("[dim #1f2937]─" * 60 + "[/]")
|
|
172
172
|
|
|
173
173
|
for rd in d.by_repo:
|
|
174
174
|
lines.append(
|
|
175
|
-
f"\n[bold #
|
|
175
|
+
f"\n[bold #8b5cf6]{rd.repo.name}[/] "
|
|
176
176
|
f"[dim]{len(rd.commits)} commit{'s' if len(rd.commits) != 1 else ''} "
|
|
177
|
-
f"[#
|
|
178
|
-
f" [#
|
|
177
|
+
f"[#22c55e]+{rd.insertions}[/]"
|
|
178
|
+
f" [#ef4444]-{rd.deletions}[/][/dim]"
|
|
179
179
|
)
|
|
180
180
|
for c in rd.commits:
|
|
181
181
|
rel = relative_time(c.ts)
|
|
182
|
-
stats = f"[#
|
|
182
|
+
stats = f"[#22c55e]+{c.insertions}[/] [#ef4444]-{c.deletions}[/]" if (c.insertions or c.deletions) else ""
|
|
183
183
|
msg = c.message[:70]
|
|
184
184
|
lines.append(
|
|
185
|
-
f" [dim #
|
|
186
|
-
f" {stats} [dim #
|
|
185
|
+
f" [dim #8b5cf6]{c.short_hash}[/] {msg}"
|
|
186
|
+
f" {stats} [dim #6b7280]{rel}[/]"
|
|
187
187
|
)
|
|
188
188
|
|
|
189
189
|
body.update("\n".join(lines))
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
fleet_status.py — Cross-repo fleet status strip for GitPulse.
|
|
3
|
+
|
|
4
|
+
A quiet single-line strip of clickable counters (dirty, behind, ahead,
|
|
5
|
+
stashes, stale) across all scanned repositories. Each chip posts a
|
|
6
|
+
FilterRequested message so the sidebar can narrow to matching repos.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from textual.app import ComposeResult
|
|
12
|
+
from textual.message import Message
|
|
13
|
+
from textual.widget import Widget
|
|
14
|
+
from textual.widgets import Static
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from gitpulse.git_ops import RepoInfo, RepoStatus
|
|
18
|
+
except ImportError:
|
|
19
|
+
from git_ops import RepoInfo, RepoStatus # type: ignore[no-redef]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FleetChip(Static):
|
|
23
|
+
"""A single clickable counter chip in the fleet status strip."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, category: str, **kwargs) -> None:
|
|
26
|
+
super().__init__(**kwargs)
|
|
27
|
+
self.category = category
|
|
28
|
+
|
|
29
|
+
def on_click(self) -> None:
|
|
30
|
+
self.post_message(FleetStatus.FilterRequested(self.category))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FleetStatus(Widget):
|
|
34
|
+
"""
|
|
35
|
+
Quiet single-row strip above the sidebar showing cross-repo counters.
|
|
36
|
+
|
|
37
|
+
Counters: dirty, behind, ahead, stashes, stale — plus an 'all' reset chip.
|
|
38
|
+
Clicking a chip filters the sidebar to the matching repos.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
class FilterRequested(Message):
|
|
42
|
+
"""Posted when a chip is clicked; carries the category to filter by."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, category: str) -> None:
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.category = category
|
|
47
|
+
|
|
48
|
+
def __init__(self, **kwargs) -> None:
|
|
49
|
+
super().__init__(**kwargs)
|
|
50
|
+
self._active_filter: str = ""
|
|
51
|
+
|
|
52
|
+
def compose(self) -> ComposeResult:
|
|
53
|
+
yield Static("fleet", id="fleet-label", markup=False)
|
|
54
|
+
yield FleetChip("dirty", id="chip-dirty")
|
|
55
|
+
yield FleetChip("behind", id="chip-behind")
|
|
56
|
+
yield FleetChip("ahead", id="chip-ahead")
|
|
57
|
+
yield FleetChip("stashes", id="chip-stashes")
|
|
58
|
+
yield FleetChip("stale", id="chip-stale")
|
|
59
|
+
yield FleetChip("all", id="chip-all")
|
|
60
|
+
|
|
61
|
+
def on_mount(self) -> None:
|
|
62
|
+
self._set_chip("chip-dirty", "dirty", 0, "#f59e0b")
|
|
63
|
+
self._set_chip("chip-behind", "behind", 0, "#ef4444")
|
|
64
|
+
self._set_chip("chip-ahead", "ahead", 0, "#22c55e")
|
|
65
|
+
self._set_chip("chip-stashes", "stash", 0, "#8b5cf6")
|
|
66
|
+
self._set_chip("chip-stale", "stale", 0, "#6b7280")
|
|
67
|
+
self.query_one("#chip-all", FleetChip).update("[#6b7280]all[/]")
|
|
68
|
+
|
|
69
|
+
def update_counters(self, repos: list[RepoInfo]) -> None:
|
|
70
|
+
"""Recompute all chips from the current repo list."""
|
|
71
|
+
n_dirty = sum(1 for r in repos if r.status != RepoStatus.CLEAN)
|
|
72
|
+
total_behind = sum(r.behind for r in repos)
|
|
73
|
+
n_ahead = sum(1 for r in repos if r.ahead > 0)
|
|
74
|
+
total_stashes = sum(r.stash_count for r in repos)
|
|
75
|
+
n_stale = sum(1 for r in repos if r.has_stale_branches)
|
|
76
|
+
|
|
77
|
+
self._set_chip("chip-dirty", "dirty", n_dirty, "#f59e0b")
|
|
78
|
+
self._set_chip("chip-behind", "behind", total_behind, "#ef4444")
|
|
79
|
+
self._set_chip("chip-ahead", "ahead", n_ahead, "#22c55e")
|
|
80
|
+
self._set_chip("chip-stashes", "stash", total_stashes, "#8b5cf6")
|
|
81
|
+
self._set_chip("chip-stale", "stale", n_stale, "#6b7280")
|
|
82
|
+
|
|
83
|
+
def set_active_filter(self, category: str) -> None:
|
|
84
|
+
"""Highlight the active filter chip and clear the others."""
|
|
85
|
+
self._active_filter = category
|
|
86
|
+
chip_map = {
|
|
87
|
+
"dirty": "chip-dirty", "behind": "chip-behind",
|
|
88
|
+
"ahead": "chip-ahead", "stashes": "chip-stashes",
|
|
89
|
+
"stale": "chip-stale", "all": "chip-all",
|
|
90
|
+
}
|
|
91
|
+
for cat, cid in chip_map.items():
|
|
92
|
+
chip: FleetChip = self.query_one(f"#{cid}", FleetChip)
|
|
93
|
+
chip.set_class(cat == category and category not in ("all", ""), "-active-filter")
|
|
94
|
+
|
|
95
|
+
def _set_chip(self, widget_id: str, label: str, count: int, color: str) -> None:
|
|
96
|
+
chip: FleetChip = self.query_one(f"#{widget_id}", FleetChip)
|
|
97
|
+
if count == 0:
|
|
98
|
+
chip.update(f"[#6b7280]{label} 0[/]")
|
|
99
|
+
else:
|
|
100
|
+
chip.update(f"[{color}]{label} {count}[/]")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
header.py — Slim application header for GitPulse.
|
|
3
|
+
|
|
4
|
+
A single-row bar holding the logo, the seven content tabs, and the global
|
|
5
|
+
action hints. Replaces Textual's built-in Header. Clicking a tab (or the
|
|
6
|
+
prev/next tab keybindings handled by the app) posts an AppHeader.TabChanged
|
|
7
|
+
message; the app switches the ContentSwitcher pane in response.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.message import Message
|
|
14
|
+
from textual.widget import Widget
|
|
15
|
+
from textual.widgets import Static
|
|
16
|
+
from textual.containers import Horizontal
|
|
17
|
+
|
|
18
|
+
# (tab id, display label) — order defines left-to-right tab order.
|
|
19
|
+
TABS: list[tuple[str, str]] = [
|
|
20
|
+
("status", "Status"),
|
|
21
|
+
("commits", "Commits"),
|
|
22
|
+
("diff", "Diff"),
|
|
23
|
+
("branches", "Branches"),
|
|
24
|
+
("remotes", "Remotes"),
|
|
25
|
+
("tags", "Tags"),
|
|
26
|
+
("tree", "Tree"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
TAB_IDS = [t[0] for t in TABS]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HeaderTab(Static):
|
|
33
|
+
"""A single clickable tab label in the header."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, tab_id: str, label: str, active: bool = False, **kwargs) -> None:
|
|
36
|
+
super().__init__(label, **kwargs)
|
|
37
|
+
self.tab_id = tab_id
|
|
38
|
+
if active:
|
|
39
|
+
self.add_class("-active")
|
|
40
|
+
|
|
41
|
+
def on_click(self) -> None:
|
|
42
|
+
self.post_message(AppHeader.TabChanged(self.tab_id))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AppHeader(Widget):
|
|
46
|
+
"""Slim top header: logo · tabs · action hints."""
|
|
47
|
+
|
|
48
|
+
class TabChanged(Message):
|
|
49
|
+
"""Posted when the active content tab should change."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, tab_id: str) -> None:
|
|
52
|
+
super().__init__()
|
|
53
|
+
self.tab_id = tab_id
|
|
54
|
+
|
|
55
|
+
def __init__(self, active_tab: str = "status", **kwargs) -> None:
|
|
56
|
+
super().__init__(**kwargs)
|
|
57
|
+
self._active = active_tab
|
|
58
|
+
|
|
59
|
+
def compose(self) -> ComposeResult:
|
|
60
|
+
yield Static(
|
|
61
|
+
"[bold #d1d5db]GitPulse[/] [#6b7280]Fleet overview[/]",
|
|
62
|
+
id="header-logo",
|
|
63
|
+
markup=True,
|
|
64
|
+
)
|
|
65
|
+
with Horizontal(id="header-tabs"):
|
|
66
|
+
for tid, label in TABS:
|
|
67
|
+
yield HeaderTab(
|
|
68
|
+
tid, label, active=(tid == self._active), id=f"htab-{tid}"
|
|
69
|
+
)
|
|
70
|
+
yield Static(
|
|
71
|
+
"[#6b7280]/ Search r Refresh ? Help[/]",
|
|
72
|
+
id="header-actions",
|
|
73
|
+
markup=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def set_active(self, tab_id: str) -> None:
|
|
77
|
+
"""Highlight *tab_id* and clear the highlight from the others."""
|
|
78
|
+
self._active = tab_id
|
|
79
|
+
for tid in TAB_IDS:
|
|
80
|
+
try:
|
|
81
|
+
tab = self.query_one(f"#htab-{tid}", HeaderTab)
|
|
82
|
+
except Exception:
|
|
83
|
+
continue
|
|
84
|
+
tab.set_class(tid == tab_id, "-active")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
help_modal.py — Keyboard shortcut cheat-sheet modal for GitPulse.
|
|
3
|
+
|
|
4
|
+
Opened with '?'. Lists the global and context keybindings in a single
|
|
5
|
+
centered panel.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.screen import ModalScreen
|
|
13
|
+
from textual.widgets import Static
|
|
14
|
+
from textual.containers import Container
|
|
15
|
+
|
|
16
|
+
_SECTIONS: list[tuple[str, list[tuple[str, str]]]] = [
|
|
17
|
+
("Global", [
|
|
18
|
+
("/", "Search / filter repos"),
|
|
19
|
+
("r", "Refresh — rescan all repos"),
|
|
20
|
+
("w", "Toggle watch mode"),
|
|
21
|
+
("d", "Activity digest"),
|
|
22
|
+
(":", "Bulk action palette"),
|
|
23
|
+
("b", "Stale-branch cleanup"),
|
|
24
|
+
("[ ]", "Previous / next tab"),
|
|
25
|
+
("?", "This help"),
|
|
26
|
+
("q", "Quit"),
|
|
27
|
+
]),
|
|
28
|
+
("Status tab", [
|
|
29
|
+
("s / u", "Stage / unstage file"),
|
|
30
|
+
("a / U", "Stage all / unstage all"),
|
|
31
|
+
("c", "Commit staged changes"),
|
|
32
|
+
("n", "New branch"),
|
|
33
|
+
("z / Z", "Create / pop stash"),
|
|
34
|
+
]),
|
|
35
|
+
("Other tabs", [
|
|
36
|
+
("Enter", "Switch branch · view commit diff · preview file"),
|
|
37
|
+
("d", "Delete branch · view commit diff"),
|
|
38
|
+
("f / p / P", "Fetch / pull / push (Remotes)"),
|
|
39
|
+
]),
|
|
40
|
+
("Sidebar", [
|
|
41
|
+
("↑ ↓", "Navigate repositories"),
|
|
42
|
+
("Space", "Toggle multi-select"),
|
|
43
|
+
("*", "Select all visible"),
|
|
44
|
+
]),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class HelpModal(ModalScreen):
|
|
49
|
+
"""Centered keyboard-shortcut reference."""
|
|
50
|
+
|
|
51
|
+
BINDINGS = [Binding("escape,q,question_mark", "close", "Close", show=True)]
|
|
52
|
+
|
|
53
|
+
DEFAULT_CSS = """
|
|
54
|
+
HelpModal {
|
|
55
|
+
align: center middle;
|
|
56
|
+
}
|
|
57
|
+
#help-frame {
|
|
58
|
+
width: 64;
|
|
59
|
+
height: auto;
|
|
60
|
+
max-height: 90%;
|
|
61
|
+
padding: 1 2;
|
|
62
|
+
background: #111827;
|
|
63
|
+
border: solid #8b5cf6;
|
|
64
|
+
}
|
|
65
|
+
#help-title {
|
|
66
|
+
text-style: bold;
|
|
67
|
+
color: #8b5cf6;
|
|
68
|
+
width: 100%;
|
|
69
|
+
text-align: center;
|
|
70
|
+
margin-bottom: 1;
|
|
71
|
+
}
|
|
72
|
+
#help-body {
|
|
73
|
+
width: 100%;
|
|
74
|
+
height: auto;
|
|
75
|
+
}
|
|
76
|
+
#help-footer {
|
|
77
|
+
width: 100%;
|
|
78
|
+
text-align: center;
|
|
79
|
+
color: #6b7280;
|
|
80
|
+
margin-top: 1;
|
|
81
|
+
}
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def compose(self) -> ComposeResult:
|
|
85
|
+
with Container(id="help-frame"):
|
|
86
|
+
yield Static("Keyboard Shortcuts", id="help-title", markup=False)
|
|
87
|
+
yield Static(self._body(), id="help-body", markup=True)
|
|
88
|
+
yield Static("Esc / q to close", id="help-footer", markup=False)
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _body() -> str:
|
|
92
|
+
lines: list[str] = []
|
|
93
|
+
for section, rows in _SECTIONS:
|
|
94
|
+
lines.append(f"[bold #d1d5db]{section}[/]")
|
|
95
|
+
for key, desc in rows:
|
|
96
|
+
lines.append(f" [#8b5cf6]{key:<10}[/] [#d1d5db]{desc}[/]")
|
|
97
|
+
lines.append("")
|
|
98
|
+
return "\n".join(lines).rstrip()
|
|
99
|
+
|
|
100
|
+
def action_close(self) -> None:
|
|
101
|
+
self.dismiss()
|