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.
Files changed (36) hide show
  1. {gitpulse_tui-1.2.7/gitpulse_tui.egg-info → gitpulse_tui-1.2.9}/PKG-INFO +1 -1
  2. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/README.md +19 -1
  3. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/git_ops.py +8 -8
  4. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/main.py +41 -2
  5. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/bulk_results.py +10 -10
  6. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/command_palette.py +6 -6
  7. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/digest_screen.py +24 -24
  8. gitpulse_tui-1.2.9/gitpulse/ui/fleet_status.py +100 -0
  9. gitpulse_tui-1.2.9/gitpulse/ui/header.py +84 -0
  10. gitpulse_tui-1.2.9/gitpulse/ui/help_modal.py +101 -0
  11. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/sidebar.py +82 -115
  12. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/stale_screen.py +20 -20
  13. gitpulse_tui-1.2.9/gitpulse/ui/styles.tcss +569 -0
  14. gitpulse_tui-1.2.9/gitpulse/ui/summary_cards.py +93 -0
  15. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/tabs.py +278 -222
  16. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/utils.py +1 -1
  17. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9/gitpulse_tui.egg-info}/PKG-INFO +1 -1
  18. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse_tui.egg-info/SOURCES.txt +3 -0
  19. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/pyproject.toml +1 -1
  20. gitpulse_tui-1.2.7/gitpulse/ui/fleet_status.py +0 -138
  21. gitpulse_tui-1.2.7/gitpulse/ui/styles.tcss +0 -459
  22. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/LICENSE +0 -0
  23. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/__init__.py +0 -0
  24. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/__main__.py +0 -0
  25. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/config.py +0 -0
  26. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/digest.py +0 -0
  27. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/parallel.py +0 -0
  28. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/scanner.py +0 -0
  29. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/stale.py +0 -0
  30. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/ui/__init__.py +0 -0
  31. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse/watcher.py +0 -0
  32. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse_tui.egg-info/dependency_links.txt +0 -0
  33. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse_tui.egg-info/entry_points.txt +0 -0
  34. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse_tui.egg-info/requires.txt +0 -0
  35. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/gitpulse_tui.egg-info/top_level.txt +0 -0
  36. {gitpulse_tui-1.2.7 → gitpulse_tui-1.2.9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitpulse-tui
3
- Version: 1.2.7
3
+ Version: 1.2.9
4
4
  Summary: Git Repo Dashboard TUI — live status, commits, diffs, and branches in your terminal
5
5
  Requires-Python: >=3.10
6
6
  License-File: LICENSE
@@ -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
+ ![GitPulse demo](ss/gitpulse.gif)
25
+
9
26
  **📋 Status tab** — repo summary panel, staged/unstaged/untracked file lists, stash entries
10
27
 
11
28
  ![Status tab](ss/status.png)
@@ -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 #ff2d4a]📁 {repo_name}[/]",
729
- guide_style="#2a2a3a",
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 #e040fb]📂 {name}[/]")
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"[#3ddc84] {name}[/]"
745
+ label = f"[#22c55e]{name}[/]"
746
746
  elif ext in ("md", "rst", "txt"):
747
- label = f"[#ffb74d] {name}[/]"
747
+ label = f"[#f59e0b]{name}[/]"
748
748
  elif ext in ("json", "yaml", "yml", "toml", "ini", "cfg", "env"):
749
- label = f"[#4dd0e1] {name}[/]"
749
+ label = f"[#8b5cf6]{name}[/]"
750
750
  elif ext in ("sh", "bash", "zsh"):
751
- label = f"[#ff5252] {name}[/]"
751
+ label = f"[#ef4444]{name}[/]"
752
752
  else:
753
- label = f"[#d4d4dc] {name}[/]"
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 Header, Footer, Input
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 Header()
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: #1a1a24;
34
- border: thick #e040fb;
33
+ background: #111827;
34
+ border: solid #8b5cf6;
35
35
  }
36
36
  #results-title {
37
37
  dock: top;
38
38
  height: 1;
39
- background: #242430;
40
- color: #e040fb;
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: #1a1a24;
52
- color: #555568;
51
+ background: #111827;
52
+ color: #6b7280;
53
53
  padding: 0 1;
54
- border-top: solid #2a2a3a;
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 #ff5252]ERROR[/]"
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 #ff5252]FAIL[/]"
93
+ status = "[bold #ef4444]FAIL[/]"
94
94
  output = result[:80]
95
95
  else:
96
- status = "[bold #3ddc84]OK[/]"
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: #1a1a24;
43
- border: thick #ff2d4a;
42
+ background: #111827;
43
+ border: solid #8b5cf6;
44
44
  }
45
45
  #palette-title {
46
46
  text-style: bold;
47
- color: #ff2d4a;
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: #ffb74d;
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("Bulk Action", id="palette-title", markup=False)
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 #3ddc84]{label}[/] [dim #555568]{desc}[/]", markup=True),
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: #1a1a24;
48
- border: thick #ff2d4a;
47
+ background: #111827;
48
+ border: solid #8b5cf6;
49
49
  }
50
50
  #digest-header {
51
51
  dock: top;
52
52
  height: 3;
53
- background: #242430;
54
- color: #ff2d4a;
53
+ background: #1f2937;
54
+ color: #8b5cf6;
55
55
  text-style: bold;
56
56
  padding: 0 2;
57
- border-bottom: heavy #2a2a3a;
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: #e040fb;
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: #1a1a24;
78
- color: #555568;
77
+ background: #111827;
78
+ color: #6b7280;
79
79
  padding: 0 1;
80
- border-top: solid #2a2a3a;
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("📋 Activity Digest", markup=False)
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"[#e040fb]window: {self._window}[/]")
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 #ff5252]Error: {e}[/]")
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 #ff5252]Error building digest: {event.worker.error}[/]")
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 #555568] Tip: configure author emails in ~/.config/gitpulse/config.toml[/]"
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 #ff2d4a]{d.total_commits}[/] commits across "
167
- f"[bold #3ddc84]{d.repos_active}[/] repos "
168
- f"[#3ddc84]+{d.total_insertions}[/] [#ff5252]-{d.total_deletions}[/] lines "
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 #2a2a3a]─" * 60 + "[/]")
171
+ lines.append("[dim #1f2937]─" * 60 + "[/]")
172
172
 
173
173
  for rd in d.by_repo:
174
174
  lines.append(
175
- f"\n[bold #e040fb]📁 {rd.repo.name}[/] "
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"[#3ddc84]+{rd.insertions}[/]"
178
- f" [#ff5252]-{rd.deletions}[/][/dim]"
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"[#3ddc84]+{c.insertions}[/] [#ff5252]-{c.deletions}[/]" if (c.insertions or c.deletions) else ""
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 #ff2d4a]{c.short_hash}[/] {msg}"
186
- f" {stats} [dim #555568]{rel}[/]"
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()