gitpulse-tui 1.2.1__tar.gz → 1.2.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.
- {gitpulse_tui-1.2.1/gitpulse_tui.egg-info → gitpulse_tui-1.2.2}/PKG-INFO +1 -1
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/README.md +14 -3
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/git_ops.py +8 -8
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/bulk_results.py +10 -10
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/command_palette.py +5 -6
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/digest_screen.py +23 -23
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/fleet_status.py +21 -17
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/sidebar.py +39 -46
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/stale_screen.py +20 -22
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/styles.tcss +133 -104
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/tabs.py +79 -79
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/utils.py +1 -1
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2/gitpulse_tui.egg-info}/PKG-INFO +1 -1
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/pyproject.toml +1 -1
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/LICENSE +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/__init__.py +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/__main__.py +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/config.py +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/digest.py +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/main.py +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/parallel.py +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/scanner.py +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/stale.py +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/ui/__init__.py +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse/watcher.py +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/SOURCES.txt +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/dependency_links.txt +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/entry_points.txt +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/requires.txt +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/gitpulse_tui.egg-info/top_level.txt +0 -0
- {gitpulse_tui-1.2.1 → gitpulse_tui-1.2.2}/setup.cfg +0 -0
|
@@ -14,15 +14,26 @@ Built with **Python**, **Textual**, **Rich**, and **GitPython**.
|
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
17
|
-
##
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
The easiest way to install GitPulse is via PyPI using `pip` or `pipx`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install gitpulse-tui
|
|
23
|
+
```
|
|
24
|
+
*(We recommend using `pipx install gitpulse-tui` to install it in an isolated environment)*
|
|
25
|
+
|
|
26
|
+
### Install from source
|
|
27
|
+
|
|
28
|
+
If you prefer to install from source or want to contribute to the project:
|
|
18
29
|
|
|
19
30
|
```bash
|
|
20
|
-
git clone https://github.com/
|
|
31
|
+
git clone https://github.com/lebiraja/git-tui.git
|
|
21
32
|
cd git-tui
|
|
22
33
|
./install.sh
|
|
23
34
|
```
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
The installer:
|
|
26
37
|
- Checks your Python version (3.10+ required)
|
|
27
38
|
- Creates a virtual environment automatically
|
|
28
39
|
- Installs all dependencies
|
|
@@ -711,8 +711,8 @@ def get_file_tree(path: Path) -> "rich.tree.Tree":
|
|
|
711
711
|
# Render the dict structure into a Rich Tree
|
|
712
712
|
repo_name = path.name
|
|
713
713
|
rich_tree = RichTree(
|
|
714
|
-
f"[bold #
|
|
715
|
-
guide_style="#
|
|
714
|
+
f"[bold #ff2d4a]📁 {repo_name}[/]",
|
|
715
|
+
guide_style="#2a2a3a",
|
|
716
716
|
)
|
|
717
717
|
|
|
718
718
|
def build_tree(node: "RichTree", d: dict) -> None:
|
|
@@ -721,22 +721,22 @@ def get_file_tree(path: Path) -> "rich.tree.Tree":
|
|
|
721
721
|
files = sorted(k for k, v in d.items() if v is None)
|
|
722
722
|
|
|
723
723
|
for name in dirs:
|
|
724
|
-
branch = node.add(f"[bold #
|
|
724
|
+
branch = node.add(f"[bold #e040fb]📂 {name}[/]")
|
|
725
725
|
build_tree(branch, d[name])
|
|
726
726
|
|
|
727
727
|
for name in files:
|
|
728
728
|
# Color-code by extension
|
|
729
729
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
730
730
|
if ext in ("py", "js", "ts", "go", "rs", "c", "cpp", "java"):
|
|
731
|
-
label = f"[#
|
|
731
|
+
label = f"[#3ddc84] {name}[/]"
|
|
732
732
|
elif ext in ("md", "rst", "txt"):
|
|
733
|
-
label = f"[#
|
|
733
|
+
label = f"[#ffb74d] {name}[/]"
|
|
734
734
|
elif ext in ("json", "yaml", "yml", "toml", "ini", "cfg", "env"):
|
|
735
|
-
label = f"[#
|
|
735
|
+
label = f"[#4dd0e1] {name}[/]"
|
|
736
736
|
elif ext in ("sh", "bash", "zsh"):
|
|
737
|
-
label = f"[#
|
|
737
|
+
label = f"[#ff5252] {name}[/]"
|
|
738
738
|
else:
|
|
739
|
-
label = f"[#
|
|
739
|
+
label = f"[#d4d4dc] {name}[/]"
|
|
740
740
|
node.add(label)
|
|
741
741
|
|
|
742
742
|
build_tree(rich_tree, root_dict)
|
|
@@ -30,14 +30,14 @@ class BulkResultsScreen(ModalScreen):
|
|
|
30
30
|
#results-frame {
|
|
31
31
|
width: 88%;
|
|
32
32
|
height: 75%;
|
|
33
|
-
background: #
|
|
34
|
-
border: thick #
|
|
33
|
+
background: #1a1a24;
|
|
34
|
+
border: thick #e040fb;
|
|
35
35
|
}
|
|
36
36
|
#results-title {
|
|
37
37
|
dock: top;
|
|
38
38
|
height: 1;
|
|
39
|
-
background: #
|
|
40
|
-
color: #
|
|
39
|
+
background: #242430;
|
|
40
|
+
color: #e040fb;
|
|
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: #1a1a24;
|
|
52
|
+
color: #555568;
|
|
53
53
|
padding: 0 1;
|
|
54
|
-
border-top: solid #
|
|
54
|
+
border-top: solid #2a2a3a;
|
|
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 #ff5252]ERROR[/]"
|
|
91
91
|
output = str(result)[:80]
|
|
92
92
|
elif isinstance(result, str) and result.lower().startswith("error"):
|
|
93
|
-
status = "[bold #
|
|
93
|
+
status = "[bold #ff5252]FAIL[/]"
|
|
94
94
|
output = result[:80]
|
|
95
95
|
else:
|
|
96
|
-
status = "[bold #
|
|
96
|
+
status = "[bold #3ddc84]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: thick #
|
|
42
|
+
background: #1a1a24;
|
|
43
|
+
border: thick #ff2d4a;
|
|
44
44
|
}
|
|
45
45
|
#palette-title {
|
|
46
46
|
text-style: bold;
|
|
47
|
-
color: #
|
|
47
|
+
color: #ff2d4a;
|
|
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: #ffb74d;
|
|
55
55
|
margin-bottom: 1;
|
|
56
56
|
width: 100%;
|
|
57
57
|
height: 1;
|
|
@@ -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 #3ddc84]{label}[/] [dim #555568]{desc}[/]", markup=True),
|
|
97
97
|
id=f"action-{key}",
|
|
98
98
|
))
|
|
99
99
|
if actions:
|
|
@@ -120,7 +120,6 @@ class CommandPaletteModal(ModalScreen):
|
|
|
120
120
|
lv: ListView = self.query_one("#palette-list", ListView)
|
|
121
121
|
item = lv.highlighted_child
|
|
122
122
|
if item is None and self._filtered:
|
|
123
|
-
# If nothing highlighted but there's a match, pick first
|
|
124
123
|
self._dispatch(self._filtered[0][0])
|
|
125
124
|
return
|
|
126
125
|
if item is not None and item.id and item.id.startswith("action-"):
|
|
@@ -51,23 +51,23 @@ class DigestScreen(ModalScreen):
|
|
|
51
51
|
#digest-frame {
|
|
52
52
|
width: 95%;
|
|
53
53
|
height: 90%;
|
|
54
|
-
background: #
|
|
55
|
-
border: thick #
|
|
54
|
+
background: #1a1a24;
|
|
55
|
+
border: thick #ff2d4a;
|
|
56
56
|
}
|
|
57
57
|
#digest-header {
|
|
58
58
|
dock: top;
|
|
59
59
|
height: 3;
|
|
60
|
-
background: #
|
|
61
|
-
color: #
|
|
60
|
+
background: #242430;
|
|
61
|
+
color: #ff2d4a;
|
|
62
62
|
text-style: bold;
|
|
63
63
|
padding: 0 2;
|
|
64
|
-
border-bottom: heavy #
|
|
64
|
+
border-bottom: heavy #2a2a3a;
|
|
65
65
|
layout: horizontal;
|
|
66
66
|
align: left middle;
|
|
67
67
|
}
|
|
68
68
|
#digest-window-label {
|
|
69
69
|
width: auto;
|
|
70
|
-
color: #
|
|
70
|
+
color: #e040fb;
|
|
71
71
|
margin-left: 2;
|
|
72
72
|
}
|
|
73
73
|
#digest-scroll {
|
|
@@ -81,10 +81,10 @@ class DigestScreen(ModalScreen):
|
|
|
81
81
|
#digest-footer {
|
|
82
82
|
dock: bottom;
|
|
83
83
|
height: 1;
|
|
84
|
-
background: #
|
|
85
|
-
color: #
|
|
84
|
+
background: #1a1a24;
|
|
85
|
+
color: #555568;
|
|
86
86
|
padding: 0 1;
|
|
87
|
-
border-top: solid #
|
|
87
|
+
border-top: solid #2a2a3a;
|
|
88
88
|
}
|
|
89
89
|
"""
|
|
90
90
|
|
|
@@ -123,7 +123,7 @@ class DigestScreen(ModalScreen):
|
|
|
123
123
|
|
|
124
124
|
def _load_digest(self) -> None:
|
|
125
125
|
label: Static = self.query_one("#digest-window-label", Static)
|
|
126
|
-
label.update(f"[#
|
|
126
|
+
label.update(f"[#e040fb]window: {self._window}[/]")
|
|
127
127
|
|
|
128
128
|
body: Static = self.query_one("#digest-body", Static)
|
|
129
129
|
body.update("[dim italic]Computing digest…[/]")
|
|
@@ -131,7 +131,7 @@ class DigestScreen(ModalScreen):
|
|
|
131
131
|
try:
|
|
132
132
|
since_ts = parse_since(self._window)
|
|
133
133
|
except ValueError as e:
|
|
134
|
-
body.update(f"[bold #
|
|
134
|
+
body.update(f"[bold #ff5252]Error: {e}[/]")
|
|
135
135
|
return
|
|
136
136
|
|
|
137
137
|
# Run in a worker so we don't block the UI
|
|
@@ -148,7 +148,7 @@ class DigestScreen(ModalScreen):
|
|
|
148
148
|
self._render_digest()
|
|
149
149
|
elif event.state == WorkerState.ERROR:
|
|
150
150
|
body: Static = self.query_one("#digest-body", Static)
|
|
151
|
-
body.update(f"[bold #
|
|
151
|
+
body.update(f"[bold #ff5252]Error building digest: {event.worker.error}[/]")
|
|
152
152
|
|
|
153
153
|
def _render_digest(self) -> None:
|
|
154
154
|
d = self._digest
|
|
@@ -160,34 +160,34 @@ class DigestScreen(ModalScreen):
|
|
|
160
160
|
if d.total_commits == 0:
|
|
161
161
|
body.update(
|
|
162
162
|
"[dim italic] No commits found for this window and author pattern.[/]\n"
|
|
163
|
-
"[dim #
|
|
163
|
+
"[dim #555568] Tip: configure author emails in ~/.config/gitpulse/config.toml[/]"
|
|
164
164
|
)
|
|
165
165
|
return
|
|
166
166
|
|
|
167
167
|
lines: list[str] = []
|
|
168
168
|
since_str = datetime.fromtimestamp(d.since_ts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
|
169
169
|
lines.append(
|
|
170
|
-
f"[bold #
|
|
171
|
-
f"[bold #
|
|
172
|
-
f"[#
|
|
170
|
+
f"[bold #ff2d4a]{d.total_commits}[/] commits across "
|
|
171
|
+
f"[bold #3ddc84]{d.repos_active}[/] repos "
|
|
172
|
+
f"[#3ddc84]+{d.total_insertions}[/] [#ff5252]-{d.total_deletions}[/] lines "
|
|
173
173
|
f"[dim]since {since_str} UTC[/]"
|
|
174
174
|
)
|
|
175
|
-
lines.append("[dim #
|
|
175
|
+
lines.append("[dim #2a2a3a]─" * 60 + "[/]")
|
|
176
176
|
|
|
177
177
|
for rd in d.by_repo:
|
|
178
178
|
lines.append(
|
|
179
|
-
f"\n[bold #
|
|
179
|
+
f"\n[bold #e040fb]📁 {rd.repo.name}[/] "
|
|
180
180
|
f"[dim]{len(rd.commits)} commit{'s' if len(rd.commits) != 1 else ''} "
|
|
181
|
-
f"[#
|
|
182
|
-
f" [#
|
|
181
|
+
f"[#3ddc84]+{rd.insertions}[/]"
|
|
182
|
+
f" [#ff5252]-{rd.deletions}[/][/dim]"
|
|
183
183
|
)
|
|
184
184
|
for c in rd.commits:
|
|
185
185
|
rel = relative_time(c.ts)
|
|
186
|
-
stats = f"[#
|
|
186
|
+
stats = f"[#3ddc84]+{c.insertions}[/] [#ff5252]-{c.deletions}[/]" if (c.insertions or c.deletions) else ""
|
|
187
187
|
msg = c.message[:70]
|
|
188
188
|
lines.append(
|
|
189
|
-
f" [dim #
|
|
190
|
-
f" {stats} [dim #
|
|
189
|
+
f" [dim #ff2d4a]{c.short_hash}[/] {msg}"
|
|
190
|
+
f" {stats} [dim #555568]{rel}[/]"
|
|
191
191
|
)
|
|
192
192
|
|
|
193
193
|
body.update("\n".join(lines))
|
|
@@ -31,7 +31,7 @@ class FleetChip(Static):
|
|
|
31
31
|
content-align: center middle;
|
|
32
32
|
}
|
|
33
33
|
FleetChip:hover {
|
|
34
|
-
background: #
|
|
34
|
+
background: #2d1520;
|
|
35
35
|
}
|
|
36
36
|
"""
|
|
37
37
|
|
|
@@ -53,20 +53,21 @@ class FleetStatus(Widget):
|
|
|
53
53
|
- ahead — repos with unpushed commits
|
|
54
54
|
- stashes — total stash entries (sum)
|
|
55
55
|
- stale — repos with stale local branches
|
|
56
|
+
- all — reset chip to clear any active filter
|
|
56
57
|
"""
|
|
57
58
|
|
|
58
59
|
DEFAULT_CSS = """
|
|
59
60
|
FleetStatus {
|
|
60
61
|
height: 3;
|
|
61
|
-
background: #
|
|
62
|
-
border-bottom: heavy #
|
|
62
|
+
background: #1a1a24;
|
|
63
|
+
border-bottom: heavy #2a2a3a;
|
|
63
64
|
layout: horizontal;
|
|
64
65
|
align: left middle;
|
|
65
66
|
padding: 0 1;
|
|
66
67
|
}
|
|
67
68
|
FleetStatus > Static#fleet-label {
|
|
68
69
|
width: auto;
|
|
69
|
-
color: #
|
|
70
|
+
color: #555568;
|
|
70
71
|
margin-right: 1;
|
|
71
72
|
}
|
|
72
73
|
"""
|
|
@@ -85,13 +86,16 @@ class FleetStatus(Widget):
|
|
|
85
86
|
yield FleetChip("ahead", id="chip-ahead")
|
|
86
87
|
yield FleetChip("stashes", id="chip-stashes")
|
|
87
88
|
yield FleetChip("stale", id="chip-stale")
|
|
89
|
+
yield FleetChip("all", id="chip-all")
|
|
88
90
|
|
|
89
91
|
def on_mount(self) -> None:
|
|
90
|
-
self._set_chip("chip-dirty", "
|
|
91
|
-
self._set_chip("chip-behind", "↓", 0, "#
|
|
92
|
-
self._set_chip("chip-ahead", "↑",
|
|
93
|
-
self._set_chip("chip-stashes", "
|
|
94
|
-
self._set_chip("chip-stale", "
|
|
92
|
+
self._set_chip("chip-dirty", "◆ dirty", 0, "#ff5252")
|
|
93
|
+
self._set_chip("chip-behind", "↓ behind", 0, "#ff5252")
|
|
94
|
+
self._set_chip("chip-ahead", "↑ ahead", 0, "#ffb74d")
|
|
95
|
+
self._set_chip("chip-stashes", "⊞ stash", 0, "#4dd0e1")
|
|
96
|
+
self._set_chip("chip-stale", "☠ stale", 0, "#e040fb")
|
|
97
|
+
chip: FleetChip = self.query_one("#chip-all", FleetChip)
|
|
98
|
+
chip.update("[dim #555568]· all[/]")
|
|
95
99
|
|
|
96
100
|
def update_counters(self, repos: list[RepoInfo]) -> None:
|
|
97
101
|
"""Recompute all chips from the current repo list."""
|
|
@@ -101,15 +105,15 @@ class FleetStatus(Widget):
|
|
|
101
105
|
total_stashes = sum(r.stash_count for r in repos)
|
|
102
106
|
n_stale = sum(1 for r in repos if r.has_stale_branches)
|
|
103
107
|
|
|
104
|
-
self._set_chip("chip-dirty", "
|
|
105
|
-
self._set_chip("chip-behind", "↓", total_behind, "#
|
|
106
|
-
self._set_chip("chip-ahead", "↑",
|
|
107
|
-
self._set_chip("chip-stashes", "
|
|
108
|
-
self._set_chip("chip-stale", "
|
|
108
|
+
self._set_chip("chip-dirty", "◆ dirty", n_dirty, "#ff5252")
|
|
109
|
+
self._set_chip("chip-behind", "↓ behind", total_behind, "#ff5252")
|
|
110
|
+
self._set_chip("chip-ahead", "↑ ahead", n_ahead, "#ffb74d")
|
|
111
|
+
self._set_chip("chip-stashes", "⊞ stash", total_stashes, "#4dd0e1")
|
|
112
|
+
self._set_chip("chip-stale", "☠ stale", n_stale, "#e040fb")
|
|
109
113
|
|
|
110
|
-
def _set_chip(self, widget_id: str,
|
|
114
|
+
def _set_chip(self, widget_id: str, label: str, count: int, color: str) -> None:
|
|
111
115
|
chip: FleetChip = self.query_one(f"#{widget_id}", FleetChip)
|
|
112
116
|
if count == 0:
|
|
113
|
-
chip.update(f"[dim #
|
|
117
|
+
chip.update(f"[dim #2a2a3a]{label}: 0[/]")
|
|
114
118
|
else:
|
|
115
|
-
chip.update(f"[bold {color}]{
|
|
119
|
+
chip.update(f"[bold {color}]{label}: {count}[/]")
|
|
@@ -31,13 +31,13 @@ def _make_badge(info: RepoInfo) -> str:
|
|
|
31
31
|
"""Build a Rich markup badge string with icon and optional file count."""
|
|
32
32
|
count = info.modified_count
|
|
33
33
|
if info.status == RepoStatus.CLEAN:
|
|
34
|
-
return "[bold
|
|
34
|
+
return "[bold #3ddc84 on #0f2a1a] ✔ Clean [/]"
|
|
35
35
|
elif info.status == RepoStatus.MODIFIED:
|
|
36
|
-
label = f" ●
|
|
37
|
-
return f"[bold #
|
|
36
|
+
label = f" ● {count} modified " if count else " ● Modified "
|
|
37
|
+
return f"[bold #ffb74d on #2a1e00]{label}[/]"
|
|
38
38
|
else: # UNTRACKED
|
|
39
|
-
label = f" ○
|
|
40
|
-
return f"[bold
|
|
39
|
+
label = f" ○ {count} untracked " if count else " ○ Untracked "
|
|
40
|
+
return f"[bold #ff5252 on #2a0a0a]{label}[/]"
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
# ---------------------------------------------------------------------------
|
|
@@ -49,12 +49,12 @@ _SPARK_CHARS = " ▁▂▃▄▅▆▇█"
|
|
|
49
49
|
def _sparkline(activity: list[int]) -> str:
|
|
50
50
|
"""Build a 7-char sparkline from weekly commit counts (oldest→newest)."""
|
|
51
51
|
if not activity or len(activity) < 7:
|
|
52
|
-
return "[dim #
|
|
52
|
+
return "[dim #2a2a3a]▁▁▁▁▁▁▁[/]"
|
|
53
53
|
mx = max(activity)
|
|
54
54
|
if mx == 0:
|
|
55
|
-
return "[dim #
|
|
55
|
+
return "[dim #2a2a3a]▁▁▁▁▁▁▁[/]"
|
|
56
56
|
chars = "".join(_SPARK_CHARS[min(8, int(v / mx * 8))] for v in activity)
|
|
57
|
-
return f"[#
|
|
57
|
+
return f"[#ff2d4a]{chars}[/]"
|
|
58
58
|
|
|
59
59
|
|
|
60
60
|
# ---------------------------------------------------------------------------
|
|
@@ -88,24 +88,29 @@ class RepoListItem(ListItem):
|
|
|
88
88
|
|
|
89
89
|
# Selection checkbox prefix
|
|
90
90
|
if self._selected:
|
|
91
|
-
checkbox = "[bold #
|
|
91
|
+
checkbox = "[bold #3ddc84][✓][/] "
|
|
92
92
|
else:
|
|
93
|
-
checkbox = "[dim #
|
|
93
|
+
checkbox = "[dim #2a2a3a][ ][/] "
|
|
94
|
+
|
|
95
|
+
# Shorten path for display
|
|
96
|
+
path_str = str(info.path)
|
|
97
|
+
home = str(Path.home())
|
|
98
|
+
if path_str.startswith(home):
|
|
99
|
+
path_str = "~" + path_str[len(home):]
|
|
100
|
+
if len(path_str) > 38:
|
|
101
|
+
path_str = "…" + path_str[-37:]
|
|
94
102
|
|
|
95
103
|
# Line 1: checkbox + repo name + badge
|
|
96
|
-
line1 = f"{checkbox}[bold #
|
|
104
|
+
line1 = f"{checkbox}[bold #d4d4dc]{info.name}[/] {badge}"
|
|
97
105
|
# Line 2: branch | relative time | sparkline
|
|
98
|
-
line2 = f" [#
|
|
106
|
+
line2 = f" [#e040fb]⎇ {info.branch}[/] [dim #555568]⏱ {rel}[/] {spark}"
|
|
99
107
|
# Line 3: truncated last commit message for quick context
|
|
100
108
|
commit_msg = info.last_commit_msg
|
|
101
109
|
if len(commit_msg) > 36:
|
|
102
110
|
commit_msg = commit_msg[:35] + "…"
|
|
103
|
-
line3 = f" [dim #
|
|
111
|
+
line3 = f" [dim #555568]💬 {commit_msg}[/]" if commit_msg else " [dim #2a2a3a]no commits[/]"
|
|
104
112
|
# Line 4: truncated repo path for disambiguation
|
|
105
|
-
|
|
106
|
-
if len(path_str) > 38:
|
|
107
|
-
path_str = "…" + path_str[-37:]
|
|
108
|
-
line4 = f" [dim #3b4261]{path_str}[/]"
|
|
113
|
+
line4 = f" [dim #2a2a3a]{path_str}[/]"
|
|
109
114
|
|
|
110
115
|
yield Static(f"{line1}\n{line2}\n{line3}\n{line4}", markup=True)
|
|
111
116
|
|
|
@@ -157,45 +162,33 @@ class RepoSidebar(Static):
|
|
|
157
162
|
self._selected.discard(path)
|
|
158
163
|
else:
|
|
159
164
|
self._selected.add(path)
|
|
160
|
-
self.
|
|
165
|
+
self.post_message(self.SelectionChanged(
|
|
166
|
+
count=len(self._selected),
|
|
167
|
+
paths=list(self._selected),
|
|
168
|
+
))
|
|
161
169
|
|
|
162
170
|
def select_all_visible(self) -> None:
|
|
163
171
|
for r in self._current_repos:
|
|
164
172
|
self._selected.add(r.path)
|
|
165
|
-
self.
|
|
173
|
+
self.post_message(self.SelectionChanged(
|
|
174
|
+
count=len(self._selected),
|
|
175
|
+
paths=list(self._selected),
|
|
176
|
+
))
|
|
166
177
|
self.populate(self._current_repos)
|
|
167
178
|
|
|
168
179
|
def clear_selection(self) -> None:
|
|
169
180
|
self._selected.clear()
|
|
170
|
-
self.
|
|
181
|
+
self.post_message(self.SelectionChanged(count=0, paths=[]))
|
|
171
182
|
self.populate(self._current_repos)
|
|
172
183
|
|
|
173
184
|
def selected_repos(self) -> list[RepoInfo]:
|
|
174
185
|
return [r for r in self._current_repos if r.path in self._selected]
|
|
175
186
|
|
|
176
|
-
def _post_selection_changed(self) -> None:
|
|
177
|
-
self.post_message(self.SelectionChanged(
|
|
178
|
-
count=len(self._selected),
|
|
179
|
-
paths=list(self._selected),
|
|
180
|
-
))
|
|
181
|
-
# Update the header to show selection count
|
|
182
|
-
self._update_selection_indicator()
|
|
183
|
-
|
|
184
|
-
def _update_selection_indicator(self) -> None:
|
|
185
|
-
try:
|
|
186
|
-
title: Static = self.query_one("#sidebar-title", Static)
|
|
187
|
-
sel = len(self._selected)
|
|
188
|
-
if sel > 0:
|
|
189
|
-
# Append selection count to current title markup — regenerate
|
|
190
|
-
pass # handled in update_header when called after populate
|
|
191
|
-
except Exception:
|
|
192
|
-
pass
|
|
193
|
-
|
|
194
187
|
# ── Compose ─────────────────────────────────────────────────────────
|
|
195
188
|
|
|
196
189
|
def compose(self) -> ComposeResult:
|
|
197
190
|
yield Static(
|
|
198
|
-
"⚡ [bold #
|
|
191
|
+
"⚡ [bold #ff2d4a]GitPulse[/]",
|
|
199
192
|
id="sidebar-title",
|
|
200
193
|
markup=True,
|
|
201
194
|
)
|
|
@@ -218,23 +211,23 @@ class RepoSidebar(Static):
|
|
|
218
211
|
"""
|
|
219
212
|
title: Static = self.query_one("#sidebar-title", Static)
|
|
220
213
|
if scanning:
|
|
221
|
-
title.update("⚡ [bold #
|
|
214
|
+
title.update("⚡ [bold #ff2d4a]GitPulse[/] [dim #555568]scanning…[/]")
|
|
222
215
|
return
|
|
223
216
|
count_str = (
|
|
224
|
-
f"[dim #
|
|
217
|
+
f"[dim #555568]{count} repo{'s' if count != 1 else ''}[/]"
|
|
225
218
|
if count else ""
|
|
226
219
|
)
|
|
227
220
|
if live is True:
|
|
228
|
-
live_str = " [bold #
|
|
221
|
+
live_str = " [bold #3ddc84]●live[/]"
|
|
229
222
|
elif live is False:
|
|
230
|
-
live_str = " [dim #
|
|
223
|
+
live_str = " [dim #555568]○paused[/]"
|
|
231
224
|
else:
|
|
232
225
|
live_str = ""
|
|
233
226
|
|
|
234
227
|
sel = len(self._selected)
|
|
235
|
-
sel_str = f" [bold #
|
|
228
|
+
sel_str = f" [bold #ffb74d][{sel} sel][/]" if sel > 0 else ""
|
|
236
229
|
|
|
237
|
-
title.update(f"⚡ [bold #
|
|
230
|
+
title.update(f"⚡ [bold #ff2d4a]GitPulse[/]{live_str} {count_str}{sel_str}")
|
|
238
231
|
|
|
239
232
|
def populate(self, repos: list[RepoInfo]) -> None:
|
|
240
233
|
"""Clear and re-populate the repo list."""
|
|
@@ -245,7 +238,7 @@ class RepoSidebar(Static):
|
|
|
245
238
|
if not repos:
|
|
246
239
|
from textual.widgets import ListItem as _LI
|
|
247
240
|
list_view.append(_LI(Static(
|
|
248
|
-
"[dim italic #
|
|
241
|
+
"[dim italic #555568]\n 📂 No repositories found\n"
|
|
249
242
|
" Try a different root or\n"
|
|
250
243
|
" press r to rescan\n[/]",
|
|
251
244
|
markup=True,
|
|
@@ -38,11 +38,11 @@ class DeleteConfirmModal(ModalScreen):
|
|
|
38
38
|
width: 56;
|
|
39
39
|
height: auto;
|
|
40
40
|
padding: 1 2;
|
|
41
|
-
background: #
|
|
42
|
-
border: thick #
|
|
41
|
+
background: #1a1a24;
|
|
42
|
+
border: thick #ff5252;
|
|
43
43
|
}
|
|
44
|
-
#dconf-title { color: #
|
|
45
|
-
#dconf-info { color: #
|
|
44
|
+
#dconf-title { color: #ff5252; text-style: bold; margin-bottom: 1; }
|
|
45
|
+
#dconf-info { color: #ffb74d; margin-bottom: 1; }
|
|
46
46
|
#dconf-input { width: 100%; margin-bottom: 1; }
|
|
47
47
|
#dconf-btns { layout: horizontal; width: 100%; height: 3; align: center middle; }
|
|
48
48
|
"""
|
|
@@ -101,14 +101,14 @@ class StaleScreen(ModalScreen):
|
|
|
101
101
|
#stale-frame {
|
|
102
102
|
width: 96%;
|
|
103
103
|
height: 90%;
|
|
104
|
-
background: #
|
|
105
|
-
border: thick #
|
|
104
|
+
background: #1a1a24;
|
|
105
|
+
border: thick #e040fb;
|
|
106
106
|
}
|
|
107
107
|
#stale-header {
|
|
108
108
|
dock: top;
|
|
109
109
|
height: 1;
|
|
110
|
-
background: #
|
|
111
|
-
color: #
|
|
110
|
+
background: #242430;
|
|
111
|
+
color: #e040fb;
|
|
112
112
|
text-style: bold;
|
|
113
113
|
padding: 0 1;
|
|
114
114
|
}
|
|
@@ -116,10 +116,10 @@ class StaleScreen(ModalScreen):
|
|
|
116
116
|
#stale-footer {
|
|
117
117
|
dock: bottom;
|
|
118
118
|
height: 1;
|
|
119
|
-
background: #
|
|
120
|
-
color: #
|
|
119
|
+
background: #1a1a24;
|
|
120
|
+
color: #555568;
|
|
121
121
|
padding: 0 1;
|
|
122
|
-
border-top: solid #
|
|
122
|
+
border-top: solid #2a2a3a;
|
|
123
123
|
}
|
|
124
124
|
"""
|
|
125
125
|
|
|
@@ -142,7 +142,7 @@ class StaleScreen(ModalScreen):
|
|
|
142
142
|
|
|
143
143
|
def compose(self) -> ComposeResult:
|
|
144
144
|
with Container(id="stale-frame"):
|
|
145
|
-
yield Static("
|
|
145
|
+
yield Static(" ☠ Stale Branches", id="stale-header", markup=False)
|
|
146
146
|
with TabbedContent(id="stale-tabs"):
|
|
147
147
|
for cat, label in [
|
|
148
148
|
("stale", f"Stale ({self._stale_weeks}w+)"),
|
|
@@ -170,7 +170,7 @@ class StaleScreen(ModalScreen):
|
|
|
170
170
|
|
|
171
171
|
def _load_data(self) -> None:
|
|
172
172
|
self.query_one("#stale-header", Static).update(
|
|
173
|
-
"
|
|
173
|
+
" ☠ Stale Branches [dim #555568]loading…[/]"
|
|
174
174
|
)
|
|
175
175
|
self.run_worker(self._fetch_worker, thread=True, group="stale")
|
|
176
176
|
|
|
@@ -187,10 +187,10 @@ class StaleScreen(ModalScreen):
|
|
|
187
187
|
if event.state == WorkerState.SUCCESS and event.worker.result is not None:
|
|
188
188
|
self._categories = event.worker.result
|
|
189
189
|
self._populate_tables()
|
|
190
|
-
self.query_one("#stale-header", Static).update("
|
|
190
|
+
self.query_one("#stale-header", Static).update(" ☠ Stale Branches")
|
|
191
191
|
elif event.state == WorkerState.ERROR:
|
|
192
192
|
self.query_one("#stale-header", Static).update(
|
|
193
|
-
f"
|
|
193
|
+
f" ☠ Error: {event.worker.error}"
|
|
194
194
|
)
|
|
195
195
|
|
|
196
196
|
def _populate_tables(self) -> None:
|
|
@@ -199,12 +199,12 @@ class StaleScreen(ModalScreen):
|
|
|
199
199
|
table.clear()
|
|
200
200
|
for b in sorted(branches, key=lambda x: x.age_days, reverse=True):
|
|
201
201
|
sel_key = (b.repo_name, b.name)
|
|
202
|
-
checkbox = "[bold #
|
|
202
|
+
checkbox = "[bold #3ddc84]✓[/]" if sel_key in self._selected else "[ ]"
|
|
203
203
|
flags = []
|
|
204
|
-
if b.is_wip:
|
|
205
|
-
if b.is_merged_into_default: flags.append("[#
|
|
206
|
-
if b.is_current:
|
|
207
|
-
if not b.has_upstream:
|
|
204
|
+
if b.is_wip: flags.append("[#ffb74d]WIP[/]")
|
|
205
|
+
if b.is_merged_into_default: flags.append("[#3ddc84]merged[/]")
|
|
206
|
+
if b.is_current: flags.append("[#ff2d4a]current[/]")
|
|
207
|
+
if not b.has_upstream: flags.append("[dim]no-remote[/]")
|
|
208
208
|
flags_str = " ".join(flags) if flags else "[dim]—[/]"
|
|
209
209
|
age_str = f"{b.age_days}d"
|
|
210
210
|
msg = b.last_commit_msg[:40] + ("…" if len(b.last_commit_msg) > 40 else "")
|
|
@@ -256,7 +256,6 @@ class StaleScreen(ModalScreen):
|
|
|
256
256
|
async def _after_confirm(confirmed: bool) -> None:
|
|
257
257
|
if not confirmed:
|
|
258
258
|
return
|
|
259
|
-
# Collect BranchDetail objects for selected
|
|
260
259
|
to_delete: list[BranchDetail] = []
|
|
261
260
|
for cat_branches in self._categories.values():
|
|
262
261
|
for b in cat_branches:
|
|
@@ -274,7 +273,6 @@ class StaleScreen(ModalScreen):
|
|
|
274
273
|
|
|
275
274
|
self._selected.clear()
|
|
276
275
|
self._load_data()
|
|
277
|
-
self.app.post_message_no_wait = getattr(self.app, "post_message_no_wait", None)
|
|
278
276
|
|
|
279
277
|
self.app.push_screen(DeleteConfirmModal(count=len(self._selected)), _after_confirm)
|
|
280
278
|
|