git-juggler 0.2.1__tar.gz → 0.2.3__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 (197) hide show
  1. {git_juggler-0.2.1 → git_juggler-0.2.3}/.gitignore +2 -0
  2. {git_juggler-0.2.1 → git_juggler-0.2.3}/PKG-INFO +2 -2
  3. {git_juggler-0.2.1 → git_juggler-0.2.3}/README.md +1 -1
  4. git_juggler-0.2.3/src/backend/git_juggler/agents/__init__.py +1 -0
  5. git_juggler-0.2.1/src/backend/git_juggler/agent_hook_events.py → git_juggler-0.2.3/src/backend/git_juggler/agents/hook_events.py +12 -7
  6. {git_juggler-0.2.1/src/backend/git_juggler/agent_tracking → git_juggler-0.2.3/src/backend/git_juggler/agents/tracking}/activity_models.py +2 -0
  7. {git_juggler-0.2.1/src/backend/git_juggler/agent_tracking → git_juggler-0.2.3/src/backend/git_juggler/agents/tracking}/claude_sessions.py +3 -0
  8. git_juggler-0.2.3/src/backend/git_juggler/api/__init__.py +1 -0
  9. git_juggler-0.2.3/src/backend/git_juggler/api/agents.py +97 -0
  10. git_juggler-0.2.3/src/backend/git_juggler/api/ci.py +97 -0
  11. git_juggler-0.2.3/src/backend/git_juggler/api/config.py +114 -0
  12. git_juggler-0.2.3/src/backend/git_juggler/api/git.py +77 -0
  13. git_juggler-0.2.3/src/backend/git_juggler/api/repos.py +26 -0
  14. git_juggler-0.2.3/src/backend/git_juggler/api/terminal.py +31 -0
  15. git_juggler-0.2.3/src/backend/git_juggler/app.py +51 -0
  16. git_juggler-0.2.1/src/backend/git_juggler/ci.py → git_juggler-0.2.3/src/backend/git_juggler/ci/__init__.py +13 -2
  17. {git_juggler-0.2.1/src/backend/git_juggler → git_juggler-0.2.3/src/backend/git_juggler/ci}/github_actions.py +15 -12
  18. git_juggler-0.2.3/src/backend/git_juggler/ci/http.py +28 -0
  19. {git_juggler-0.2.1/src/backend/git_juggler → git_juggler-0.2.3/src/backend/git_juggler/ci}/jenkins.py +29 -15
  20. git_juggler-0.2.3/src/backend/git_juggler/frontend_dist/assets/index-BGVpgOst.css +1 -0
  21. git_juggler-0.2.1/src/backend/git_juggler/frontend_dist/assets/index-CnmsXu_6.js → git_juggler-0.2.3/src/backend/git_juggler/frontend_dist/assets/index-C1_44PRZ.js +15 -14
  22. git_juggler-0.2.3/src/backend/git_juggler/frontend_dist/ci-logos/jenkins.webp +0 -0
  23. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/frontend_dist/index.html +2 -2
  24. git_juggler-0.2.3/src/backend/git_juggler/frontend_dist/logo.png +0 -0
  25. git_juggler-0.2.3/src/backend/git_juggler/git/__init__.py +1 -0
  26. git_juggler-0.2.3/src/backend/git_juggler/git/command.py +50 -0
  27. git_juggler-0.2.1/src/backend/git_juggler/git_data.py → git_juggler-0.2.3/src/backend/git_juggler/git/data.py +49 -199
  28. git_juggler-0.2.1/src/backend/git_juggler/commit_detail.py → git_juggler-0.2.3/src/backend/git_juggler/git/detail.py +22 -2
  29. git_juggler-0.2.1/src/backend/git_juggler/file_diff.py → git_juggler-0.2.3/src/backend/git_juggler/git/diff.py +1 -1
  30. git_juggler-0.2.3/src/backend/git_juggler/git/refs.py +66 -0
  31. git_juggler-0.2.3/src/backend/git_juggler/git/status.py +146 -0
  32. git_juggler-0.2.1/src/backend/git_juggler/repos.py → git_juggler-0.2.3/src/backend/git_juggler/repo_discovery.py +1 -1
  33. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/schemas.py +9 -0
  34. git_juggler-0.2.3/src/backend/git_juggler/ui/__init__.py +1 -0
  35. git_juggler-0.2.1/src/backend/git_juggler/pick_folder.py → git_juggler-0.2.3/src/backend/git_juggler/ui/folder_picker.py +26 -1
  36. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_agent_hooks.py +38 -12
  37. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_agent_tracking.py +1 -1
  38. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_ci.py +14 -2
  39. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_ci_poll.py +2 -1
  40. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_ci_poll_route.py +8 -3
  41. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_claude_sessions_data.py +5 -5
  42. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_file_diff.py +15 -3
  43. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_git_data.py +67 -5
  44. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_github_actions.py +2 -2
  45. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_jenkins.py +1 -1
  46. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_opencode_sessions.py +9 -9
  47. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_pick_folder.py +1 -1
  48. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_repos.py +1 -1
  49. git_juggler-0.2.3/src/frontend/public/ci-logos/jenkins.webp +0 -0
  50. git_juggler-0.2.3/src/frontend/public/logo.png +0 -0
  51. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/App.css +373 -334
  52. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/App.tsx +13 -8
  53. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/api/client.ts +22 -1
  54. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/api/types.ts +10 -0
  55. git_juggler-0.2.3/src/frontend/src/components/Agents/AgentHoverCard.tsx +67 -0
  56. git_juggler-0.2.3/src/frontend/src/components/Agents/AgentStatusPopover.tsx +208 -0
  57. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Badges/BranchBadge.tsx +1 -1
  58. git_juggler-0.2.3/src/frontend/src/components/Badges/CiRunBadge.tsx +161 -0
  59. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Badges/StashBadge.tsx +1 -1
  60. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Badges/TagBadge.tsx +1 -1
  61. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Badges/ciStatus.tsx +4 -14
  62. git_juggler-0.2.3/src/frontend/src/components/CIRunPopover.tsx +344 -0
  63. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Commits/CommitDetail.tsx +2 -0
  64. git_juggler-0.2.3/src/frontend/src/components/Commits/LineCounts.tsx +16 -0
  65. git_juggler-0.2.3/src/frontend/src/components/Commits/UncommittedRow.tsx +241 -0
  66. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/ContextMenu/BranchContextMenu.tsx +2 -0
  67. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/ContextMenu/CommitContextMenu.tsx +14 -0
  68. git_juggler-0.2.3/src/frontend/src/components/ContextMenu/CreateBranchModal.tsx +77 -0
  69. git_juggler-0.2.3/src/frontend/src/components/ContextMenu/RepoContextMenu.tsx +101 -0
  70. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/ContextMenu/TagContextMenu.tsx +2 -0
  71. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Menu/AgentsSettings.tsx +1 -1
  72. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Menu/GitHubSettings.tsx +45 -8
  73. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Menu/JenkinsSettings.tsx +47 -10
  74. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Search/MultiSelect.tsx +2 -3
  75. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Search/SearchBox.tsx +3 -7
  76. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Sidebar/NewGroupModal.tsx +3 -1
  77. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Sidebar/RepoList.tsx +82 -110
  78. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Tabs/TabsBar.tsx +7 -3
  79. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Terminal/TerminalPanel.tsx +1 -1
  80. git_juggler-0.2.3/src/frontend/src/components/icons.tsx +71 -0
  81. git_juggler-0.2.3/src/frontend/src/lib/agentStatusPopoverData.ts +91 -0
  82. git_juggler-0.2.3/src/frontend/src/lib/ciRunPopoverData.ts +94 -0
  83. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/state/store.ts +60 -4
  84. git_juggler-0.2.3/src/frontend/tests/agentStatusPopoverData.test.ts +93 -0
  85. git_juggler-0.2.3/src/frontend/tests/ciRunPopoverData.test.ts +85 -0
  86. git_juggler-0.2.1/plans/github-actions-settings-and-build-status.md +0 -449
  87. git_juggler-0.2.1/plans/jenkins-settings-and-build-status.md +0 -351
  88. git_juggler-0.2.1/src/backend/git_juggler/app.py +0 -388
  89. git_juggler-0.2.1/src/backend/git_juggler/browse.py +0 -27
  90. git_juggler-0.2.1/src/backend/git_juggler/frontend_dist/assets/index-C-FENuHS.css +0 -1
  91. git_juggler-0.2.1/src/frontend/src/components/Agents/AgentHoverCard.tsx +0 -99
  92. git_juggler-0.2.1/src/frontend/src/components/Badges/CiRunBadge.tsx +0 -92
  93. git_juggler-0.2.1/src/frontend/src/components/Badges/icons.tsx +0 -32
  94. git_juggler-0.2.1/src/frontend/src/components/Commits/UncommittedRow.tsx +0 -128
  95. git_juggler-0.2.1/src/frontend/src/components/ContextMenu/RepoContextMenu.tsx +0 -29
  96. git_juggler-0.2.1/src/frontend/src/components/Pipelines/StageGraph.tsx +0 -159
  97. git_juggler-0.2.1/src/frontend/src/lib/stageGraphLayout.ts +0 -117
  98. git_juggler-0.2.1/src/frontend/tests/stageGraphLayout.test.ts +0 -95
  99. {git_juggler-0.2.1 → git_juggler-0.2.3}/.github/workflows/build-package.yml +0 -0
  100. {git_juggler-0.2.1 → git_juggler-0.2.3}/CLAUDE.md +0 -0
  101. {git_juggler-0.2.1 → git_juggler-0.2.3}/LICENSE +0 -0
  102. {git_juggler-0.2.1 → git_juggler-0.2.3}/notes.txt +0 -0
  103. {git_juggler-0.2.1 → git_juggler-0.2.3}/pyproject.toml +0 -0
  104. {git_juggler-0.2.1 → git_juggler-0.2.3}/scripts/build-package.sh +0 -0
  105. {git_juggler-0.2.1 → git_juggler-0.2.3}/scripts/dev.sh +0 -0
  106. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/__init__.py +0 -0
  107. /git_juggler-0.2.1/src/backend/git_juggler/agent_hooks.py → /git_juggler-0.2.3/src/backend/git_juggler/agents/hooks.py +0 -0
  108. {git_juggler-0.2.1/src/backend/git_juggler/agent_tracking → git_juggler-0.2.3/src/backend/git_juggler/agents/tracking}/__init__.py +0 -0
  109. {git_juggler-0.2.1/src/backend/git_juggler/agent_tracking → git_juggler-0.2.3/src/backend/git_juggler/agents/tracking}/claude_transcripts.py +0 -0
  110. {git_juggler-0.2.1/src/backend/git_juggler/agent_tracking → git_juggler-0.2.3/src/backend/git_juggler/agents/tracking}/git_resolver.py +0 -0
  111. {git_juggler-0.2.1/src/backend/git_juggler/agent_tracking → git_juggler-0.2.3/src/backend/git_juggler/agents/tracking}/opencode_sessions.py +0 -0
  112. {git_juggler-0.2.1/src/backend/git_juggler/agent_tracking → git_juggler-0.2.3/src/backend/git_juggler/agents/tracking}/paths.py +0 -0
  113. {git_juggler-0.2.1/src/backend/git_juggler/agent_tracking → git_juggler-0.2.3/src/backend/git_juggler/agents/tracking}/text.py +0 -0
  114. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/config.py +0 -0
  115. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/dev_app.py +0 -0
  116. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/frontend_dist/agent-logos/claude.png +0 -0
  117. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/frontend_dist/agent-logos/opencode.webp +0 -0
  118. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/frontend_dist/favicon.svg +0 -0
  119. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/frontend_dist/git-juggler.png +0 -0
  120. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/frontend_dist/icons.svg +0 -0
  121. /git_juggler-0.2.1/src/backend/git_juggler/git_utils.py → /git_juggler-0.2.3/src/backend/git_juggler/git/utils.py +0 -0
  122. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/main.py +0 -0
  123. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/terminal.py +0 -0
  124. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/git_juggler/themes.py +0 -0
  125. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_preferences.py +0 -0
  126. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/backend/tests/test_themes.py +0 -0
  127. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/.gitignore +0 -0
  128. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/README.md +0 -0
  129. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/index.html +0 -0
  130. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/package-lock.json +0 -0
  131. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/package.json +0 -0
  132. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/public/agent-logos/claude.png +0 -0
  133. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/public/agent-logos/opencode.webp +0 -0
  134. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/public/favicon.svg +0 -0
  135. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/public/git-juggler.png +0 -0
  136. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/public/icons.svg +0 -0
  137. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Agents/AgentActivityPanel.tsx +0 -0
  138. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Agents/agentFormat.ts +0 -0
  139. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Branches/DeleteBranchModal.tsx +0 -0
  140. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/CommandPalette/CommandPalette.tsx +0 -0
  141. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Commits/CommitList.tsx +0 -0
  142. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Commits/CommitRow.tsx +0 -0
  143. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/ContextMenu/CreateTagModal.tsx +0 -0
  144. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Diff/FileDiffModal.tsx +0 -0
  145. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Graph/GraphPanel.tsx +0 -0
  146. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Graph/branchColor.ts +0 -0
  147. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Graph/computeColumns.ts +0 -0
  148. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Graph/laneWidth.ts +0 -0
  149. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Menu/AgentQuickPicks.tsx +0 -0
  150. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Menu/CiPollField.tsx +0 -0
  151. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Menu/DirectoryBrowserModal.tsx +0 -0
  152. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Menu/MainMenu.tsx +0 -0
  153. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Menu/RepoPathsSettings.tsx +0 -0
  154. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Menu/ThemePicker.tsx +0 -0
  155. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Menu/ThemeQuickPicks.tsx +0 -0
  156. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Pipelines/PipelinesPanel.tsx +0 -0
  157. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Pipelines/StageStrip.tsx +0 -0
  158. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Search/TriSwitch.tsx +0 -0
  159. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Sidebar/Sidebar.tsx +0 -0
  160. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Tags/DeleteTagModal.tsx +0 -0
  161. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/Welcome/WelcomeWizard.tsx +0 -0
  162. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/inputs/NumberField.tsx +0 -0
  163. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/inputs/TextField.tsx +0 -0
  164. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/inputs/ToggleField.tsx +0 -0
  165. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/components/inputs/styles.ts +0 -0
  166. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/index.css +0 -0
  167. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/index.tsx +0 -0
  168. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/appTheme.ts +0 -0
  169. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/branchVisibility.ts +0 -0
  170. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/bundledThemes.ts +0 -0
  171. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/ciPoll.ts +0 -0
  172. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/color.ts +0 -0
  173. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/dismissOnOutsideClick.ts +0 -0
  174. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/flip.ts +0 -0
  175. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/formatDate.ts +0 -0
  176. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/preferenceSender.ts +0 -0
  177. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/scrollToCommit.ts +0 -0
  178. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/sideBySide.ts +0 -0
  179. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/stageWindow.ts +0 -0
  180. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/stripAnsi.ts +0 -0
  181. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/swallowNextClick.ts +0 -0
  182. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/lib/vscodeTheme.ts +0 -0
  183. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/state/overlayStack.ts +0 -0
  184. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/state/preferenceSync.ts +0 -0
  185. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/state/preferences.ts +0 -0
  186. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/src/state/themes.ts +0 -0
  187. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/tests/agentFormat.test.ts +0 -0
  188. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/tests/ciPoll.test.ts +0 -0
  189. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/tests/computeColumns.test.ts +0 -0
  190. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/tests/preferenceSender.test.ts +0 -0
  191. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/tests/sideBySide.test.ts +0 -0
  192. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/tests/stageWindow.test.ts +0 -0
  193. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/tests/vscodeTheme.test.ts +0 -0
  194. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/tsconfig.app.json +0 -0
  195. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/tsconfig.json +0 -0
  196. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/tsconfig.node.json +0 -0
  197. {git_juggler-0.2.1 → git_juggler-0.2.3}/src/frontend/vite.config.ts +0 -0
@@ -27,3 +27,5 @@ secrets.env
27
27
 
28
28
  # Personal task notes (see CLAUDE.md)
29
29
  todo.txt
30
+
31
+ plans/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: git-juggler
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: A local Git dashboard for people juggling agents and too many repos.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -11,7 +11,7 @@ Requires-Dist: pywinpty>=2.0; sys_platform == 'win32'
11
11
  Requires-Dist: uvicorn[standard]>=0.29
12
12
  Description-Content-Type: text/markdown
13
13
 
14
- # git-juggler
14
+ <img src="https://github.com/franz38/git-juggler/blob/main/src/frontend/public/logo.png?raw=true" alt="git-juggler" width="400">
15
15
 
16
16
  A local Git dashboard for people juggling agents and too many repos.
17
17
 
@@ -1,4 +1,4 @@
1
- # git-juggler
1
+ <img src="https://github.com/franz38/git-juggler/blob/main/src/frontend/public/logo.png?raw=true" alt="git-juggler" width="400">
2
2
 
3
3
  A local Git dashboard for people juggling agents and too many repos.
4
4
 
@@ -0,0 +1 @@
1
+ """Agent hook and activity tracking helpers."""
@@ -12,12 +12,12 @@ from dataclasses import dataclass, replace
12
12
  from pathlib import Path
13
13
  from typing import Any
14
14
 
15
- from .agent_hooks import EVENT_PATH
16
- from .agent_tracking.activity_models import ActivityEvidence, AgentRepositoryScan, AgentWorktreeActivity, SessionDetails
17
- from .agent_tracking.claude_sessions import CLAUDE_SESSIONS_DIR, ClaudeSession, read_registry, registry_signature
18
- from .agent_tracking.claude_transcripts import CLAUDE_PROJECTS_DIR, TranscriptInfo, find_transcript, read_transcript_info, transcript_signature
19
- from .agent_tracking.git_resolver import GitResolver, GitWorktreeInfo
20
- from .agent_tracking.opencode_sessions import OPENCODE_DB_PATH, OpenCodeSession, db_signature, read_session
15
+ from .hooks import EVENT_PATH
16
+ from .tracking.activity_models import ActivityEvidence, AgentRepositoryScan, AgentWorktreeActivity, SessionDetails
17
+ from .tracking.claude_sessions import CLAUDE_SESSIONS_DIR, ClaudeSession, read_registry, registry_signature
18
+ from .tracking.claude_transcripts import CLAUDE_PROJECTS_DIR, TranscriptInfo, find_transcript, read_transcript_info, transcript_signature
19
+ from .tracking.git_resolver import GitResolver, GitWorktreeInfo
20
+ from .tracking.opencode_sessions import OPENCODE_DB_PATH, OpenCodeSession, db_signature, read_session
21
21
 
22
22
 
23
23
  HOOK_ACTIVITY_TTL_MS = 120_000
@@ -151,12 +151,16 @@ class AgentHookEventReader:
151
151
  card: ClaudeSession | None = None
152
152
  opencode_session: OpenCodeSession | None = None
153
153
  busy: bool | None = None # what the agent itself reports, when it does
154
+ waiting_for: str | None = None
154
155
  if provider == "claude" and registry is not None and session_id:
155
156
  card = registry.get(session_id)
156
157
  if card is None or not _pid_alive(card.pid):
157
158
  continue
158
159
  process_pid: int | None = card.pid
159
- busy = card.status == "busy" if card.status in ("busy", "idle") else None
160
+ # "waiting" (on a permission prompt or a question) is still mid-turn.
161
+ busy = card.status in ("busy", "waiting") if card.status in ("busy", "idle", "waiting") else None
162
+ if card.status == "waiting":
163
+ waiting_for = card.waiting_for or "permission prompt"
160
164
  else:
161
165
  process_pid = next((event.agent_pid for event in reversed(session_events) if event.agent_pid is not None), None)
162
166
  if process_pid is not None and not _pid_alive(process_pid):
@@ -235,6 +239,7 @@ class AgentHookEventReader:
235
239
  process_pid=process_pid,
236
240
  name=card.name if card is not None else (opencode_session.title if opencode_session is not None else None),
237
241
  details=details,
242
+ waiting_for=waiting_for,
238
243
  )
239
244
  )
240
245
  return scans, next_change
@@ -61,3 +61,5 @@ class AgentRepositoryScan:
61
61
  process_pid: int | None = None
62
62
  name: str | None = None
63
63
  details: SessionDetails | None = None
64
+ # What the agent is blocked on ("permission prompt", "input needed"), or None.
65
+ waiting_for: str | None = None
@@ -21,6 +21,8 @@ class ClaudeSession:
21
21
  status_updated_at: int | None = None
22
22
  version: str | None = None
23
23
  entrypoint: str | None = None
24
+ # Set with status "waiting": "permission prompt" or "input needed".
25
+ waiting_for: str | None = None
24
26
 
25
27
 
26
28
  def registry_signature(directory: Path | None) -> tuple[tuple[str, int], ...] | None:
@@ -69,5 +71,6 @@ def read_registry(directory: Path | None) -> dict[str, ClaudeSession] | None:
69
71
  status_updated_at=data.get("statusUpdatedAt") if isinstance(data.get("statusUpdatedAt"), int) else None,
70
72
  version=data.get("version") if isinstance(data.get("version"), str) else None,
71
73
  entrypoint=data.get("entrypoint") if isinstance(data.get("entrypoint"), str) else None,
74
+ waiting_for=data.get("waitingFor") if isinstance(data.get("waitingFor"), str) else None,
72
75
  )
73
76
  return sessions
@@ -0,0 +1 @@
1
+ """FastAPI route modules."""
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from ..agents.hook_events import AgentHookEventReader
6
+ from ..agents.hooks import hooks_status, install_claude_hooks, install_opencode_hooks
7
+ from ..agents.tracking.activity_models import AgentRepositoryScan
8
+ from ..schemas import (
9
+ AgentActivityResponse,
10
+ AgentHookProviderStatusResponse,
11
+ AgentHooksResponse,
12
+ AgentRepositoryScanResponse,
13
+ )
14
+
15
+
16
+ router = APIRouter()
17
+ hook_event_reader = AgentHookEventReader()
18
+
19
+
20
+ def _agent_scan_response(scan: AgentRepositoryScan) -> AgentRepositoryScanResponse:
21
+ return AgentRepositoryScanResponse(
22
+ agent_pid=scan.agent_pid,
23
+ session_directory=scan.session_directory,
24
+ worktrees=[
25
+ {
26
+ "repository_id": activity.repository_id,
27
+ "worktree_path": activity.worktree_path,
28
+ "branch": activity.branch,
29
+ "commit": activity.commit,
30
+ "process_ids": activity.process_ids,
31
+ "first_seen": activity.first_seen,
32
+ "last_seen": activity.last_seen,
33
+ "last_activity": activity.last_activity,
34
+ "evidence": [evidence.__dict__ for evidence in activity.evidence],
35
+ "activity_score": activity.activity_score,
36
+ "state": activity.state,
37
+ "is_home": activity.is_home,
38
+ }
39
+ for activity in scan.worktrees
40
+ ],
41
+ scanned_at=scan.scanned_at,
42
+ state=scan.state,
43
+ provider=scan.provider,
44
+ session_id=scan.session_id,
45
+ process_pid=scan.process_pid,
46
+ name=scan.name,
47
+ details=scan.details.__dict__ if scan.details is not None else None,
48
+ waiting_for=scan.waiting_for,
49
+ )
50
+
51
+
52
+ def _hook_status_response(status) -> AgentHookProviderStatusResponse:
53
+ return AgentHookProviderStatusResponse(**status.__dict__)
54
+
55
+
56
+ @router.get("/api/agents/activity", response_model=AgentActivityResponse)
57
+ def api_agent_activity() -> AgentActivityResponse:
58
+ hook_scans = hook_event_reader.recent_scans()
59
+ scans = [_agent_scan_response(scan) for scan in hook_scans]
60
+ scanned_at = max((scan.scanned_at for scan in scans), default=0)
61
+ return AgentActivityResponse(
62
+ agents=[
63
+ {
64
+ "pid": scan.agent_pid,
65
+ "command_line": "hook activity",
66
+ "matched_pattern": "hook",
67
+ }
68
+ for scan in hook_scans
69
+ ],
70
+ scans=scans,
71
+ scanned_at=scanned_at,
72
+ )
73
+
74
+
75
+ @router.get("/api/agents/hooks", response_model=AgentHooksResponse)
76
+ def api_agent_hooks() -> AgentHooksResponse:
77
+ statuses = hooks_status()
78
+ return AgentHooksResponse(
79
+ claude=_hook_status_response(statuses["claude"]),
80
+ opencode=_hook_status_response(statuses["opencode"]),
81
+ )
82
+
83
+
84
+ @router.post(
85
+ "/api/agents/hooks/claude/install",
86
+ response_model=AgentHookProviderStatusResponse,
87
+ )
88
+ def api_install_claude_hooks() -> AgentHookProviderStatusResponse:
89
+ return _hook_status_response(install_claude_hooks())
90
+
91
+
92
+ @router.post(
93
+ "/api/agents/hooks/opencode/install",
94
+ response_model=AgentHookProviderStatusResponse,
95
+ )
96
+ def api_install_opencode_hooks() -> AgentHookProviderStatusResponse:
97
+ return _hook_status_response(install_opencode_hooks())
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ from fastapi import APIRouter, HTTPException, Query
7
+
8
+ from .. import config
9
+ from ..ci import github_actions, jenkins
10
+ from ..ci import get_active_pipelines, get_ci_run_stages, get_ci_runs, poll_ci_runs
11
+ from ..git.data import get_commit_hashes
12
+ from ..repo_discovery import list_repos, resolve_repo_path
13
+ from ..schemas import (
14
+ ActivePipeline,
15
+ CiConnectionTestResponse,
16
+ CiRunInfo,
17
+ CiStage,
18
+ GitHubConfig,
19
+ JenkinsConfig,
20
+ )
21
+
22
+
23
+ router = APIRouter()
24
+
25
+
26
+ def _resolve_repo_path(repo_id: str) -> Path:
27
+ path = resolve_repo_path(config.load_repo_paths(), repo_id)
28
+ if path is None:
29
+ raise HTTPException(status_code=404, detail="repo not found")
30
+ return path
31
+
32
+
33
+ @router.get("/api/repos/{repo_id}/ci/runs", response_model=dict[str, list[CiRunInfo]])
34
+ def api_ci_runs(repo_id: str) -> dict[str, list[CiRunInfo]]:
35
+ path = _resolve_repo_path(repo_id)
36
+ return get_ci_runs(
37
+ path,
38
+ get_commit_hashes(path),
39
+ config.load_github_config(),
40
+ config.load_jenkins_config(),
41
+ )
42
+
43
+
44
+ # Live poll of a repo's pipelines. Without `run`, every active run of the
45
+ # repo (`head_sha` narrows it where the provider can filter): used right
46
+ # after a commit action, when the run may not be known yet. With one or more
47
+ # `run=<provider>:<run_id>` (CiRunInfo.provider / run_id), only those runs,
48
+ # including their final status once finished.
49
+ @router.get("/api/repos/{repo_id}/ci/poll", response_model=list[CiRunInfo])
50
+ def api_ci_poll(
51
+ repo_id: str,
52
+ run: Annotated[list[str] | None, Query()] = None,
53
+ head_sha: str | None = None,
54
+ ) -> list[CiRunInfo]:
55
+ path = _resolve_repo_path(repo_id)
56
+ return poll_ci_runs(
57
+ path, run, head_sha, config.load_github_config(), config.load_jenkins_config()
58
+ )
59
+
60
+
61
+ # Stages (GitHub jobs / Jenkins pipeline stages) of one run. `run_id` is the
62
+ # value CiRunInfo.run_id carried; 404 when the provider has no stage data.
63
+ @router.get("/api/repos/{repo_id}/ci/stages", response_model=list[CiStage])
64
+ def api_ci_stages(repo_id: str, provider: str, run_id: str) -> list[CiStage]:
65
+ path = _resolve_repo_path(repo_id)
66
+ stages = get_ci_run_stages(
67
+ path,
68
+ provider,
69
+ run_id,
70
+ config.load_github_config(),
71
+ config.load_jenkins_config(),
72
+ )
73
+ if stages is None:
74
+ raise HTTPException(status_code=404, detail="stages not available")
75
+ return stages
76
+
77
+
78
+ # Queued/running pipelines across all repos, for the Pipelines tab.
79
+ @router.get("/api/ci/active", response_model=list[ActivePipeline])
80
+ def api_ci_active() -> list[ActivePipeline]:
81
+ return get_active_pipelines(
82
+ list_repos(config.load_repo_paths()),
83
+ config.load_github_config(),
84
+ config.load_jenkins_config(),
85
+ )
86
+
87
+
88
+ @router.post("/api/ci/github/test", response_model=CiConnectionTestResponse)
89
+ def api_test_github_connection(body: GitHubConfig) -> CiConnectionTestResponse:
90
+ ok, message = github_actions.test_connection(body.model_dump())
91
+ return CiConnectionTestResponse(ok=ok, message=message)
92
+
93
+
94
+ @router.post("/api/ci/jenkins/test", response_model=CiConnectionTestResponse)
95
+ def api_test_jenkins_connection(body: JenkinsConfig) -> CiConnectionTestResponse:
96
+ ok, message = jenkins.test_connection(body.model_dump())
97
+ return CiConnectionTestResponse(ok=ok, message=message)
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from fastapi import APIRouter, HTTPException
7
+
8
+ from .. import config
9
+ from ..schemas import (
10
+ ConfigResponse,
11
+ ConfigUpdateRequest,
12
+ PickFolderResponse,
13
+ Preferences,
14
+ ThemesResponse,
15
+ VscodeTheme,
16
+ )
17
+ from ..themes import discover_themes
18
+ from ..ui.folder_picker import NativePickerUnavailable, pick_folder
19
+
20
+
21
+ def create_config_router(root_path: Path) -> APIRouter:
22
+ router = APIRouter()
23
+ terminal_shell = "cmd" if sys.platform == "win32" else "posix"
24
+
25
+ def _current_config() -> ConfigResponse:
26
+ return ConfigResponse(
27
+ repo_paths=[str(p) for p in config.load_repo_paths()],
28
+ pinned_repo_paths=config.load_pinned_repo_paths(),
29
+ repo_groups=config.load_repo_groups(),
30
+ excluded_paths=config.load_excluded_paths(),
31
+ github=config.load_github_config(),
32
+ jenkins=config.load_jenkins_config(),
33
+ terminal_shell=terminal_shell,
34
+ )
35
+
36
+ @router.get("/api/config", response_model=ConfigResponse)
37
+ def api_get_config() -> ConfigResponse:
38
+ return _current_config()
39
+
40
+ @router.put("/api/config", response_model=ConfigResponse)
41
+ def api_update_config(body: ConfigUpdateRequest) -> ConfigResponse:
42
+ if body.repo_paths is not None:
43
+ resolved: list[Path] = []
44
+ seen: set[str] = set()
45
+ for raw in body.repo_paths:
46
+ path = Path(raw).expanduser().resolve()
47
+ if not path.is_dir():
48
+ raise HTTPException(status_code=400, detail=f"not a directory: {raw}")
49
+ key = str(path)
50
+ if key in seen:
51
+ continue
52
+ seen.add(key)
53
+ resolved.append(path)
54
+ config.save_repo_paths(resolved)
55
+
56
+ if body.pinned_repo_paths is not None:
57
+ deduped = list(dict.fromkeys(body.pinned_repo_paths))
58
+ config.save_pinned_repo_paths(deduped)
59
+
60
+ if body.repo_groups is not None:
61
+ config.save_repo_groups([group.model_dump() for group in body.repo_groups])
62
+
63
+ if body.excluded_paths is not None:
64
+ config.save_excluded_paths(body.excluded_paths)
65
+
66
+ if body.github is not None:
67
+ config.save_github_config(body.github.model_dump())
68
+
69
+ if body.jenkins is not None:
70
+ config.save_jenkins_config(body.jenkins.model_dump())
71
+
72
+ return _current_config()
73
+
74
+ # Wipes every stored setting back to a first-run state. App settings only,
75
+ # nothing here touches a repo.
76
+ @router.post("/api/config/reset", response_model=ConfigResponse)
77
+ def api_reset_config() -> ConfigResponse:
78
+ config.reset_to_factory(root_path)
79
+ return _current_config()
80
+
81
+ @router.post("/api/pick-folder", response_model=PickFolderResponse)
82
+ def api_pick_folder() -> PickFolderResponse:
83
+ try:
84
+ return PickFolderResponse(path=pick_folder())
85
+ except NativePickerUnavailable as e:
86
+ raise HTTPException(status_code=501, detail=str(e))
87
+
88
+ def _current_themes() -> ThemesResponse:
89
+ imported = [VscodeTheme(**theme) for theme in config.load_imported_themes()]
90
+ return ThemesResponse(installed=discover_themes(), imported=imported)
91
+
92
+ @router.get("/api/themes", response_model=ThemesResponse)
93
+ def api_get_themes() -> ThemesResponse:
94
+ return _current_themes()
95
+
96
+ # Stores theme *files* the user imported in the UI (app settings, not repo
97
+ # state, so this doesn't fall under the git-mutations-via-terminal rule).
98
+ @router.put("/api/themes/imported", response_model=ThemesResponse)
99
+ def api_put_imported_themes(body: list[VscodeTheme]) -> ThemesResponse:
100
+ config.save_imported_themes([theme.model_dump() for theme in body])
101
+ return _current_themes()
102
+
103
+ # UI preferences (theme, pinned themes, key bindings, agent settings, ...)
104
+ # kept server-side so every browser shows the same setup. App settings only,
105
+ # nothing here touches a repo.
106
+ @router.get("/api/preferences", response_model=Preferences)
107
+ def api_get_preferences() -> Preferences:
108
+ return config.load_preferences()
109
+
110
+ @router.put("/api/preferences", response_model=Preferences)
111
+ def api_put_preferences(body: Preferences) -> Preferences:
112
+ return config.update_preferences(body.model_dump(mode="json", exclude_unset=True))
113
+
114
+ return router
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, HTTPException, Query
6
+
7
+ from .. import config
8
+ from ..git.data import GRAPH_PAGE_SIZE, HistoryChangedError, get_graph, get_repo_status
9
+ from ..git.detail import get_commit_detail
10
+ from ..git.diff import get_commit_file_diff, get_working_file_diff
11
+ from ..repo_discovery import resolve_repo_path
12
+ from ..schemas import CommitDetail, FileDiff, GraphResponse, RepoStatusResponse
13
+
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ def _resolve_repo_path(repo_id: str) -> Path:
19
+ path = resolve_repo_path(config.load_repo_paths(), repo_id)
20
+ if path is None:
21
+ raise HTTPException(status_code=404, detail="repo not found")
22
+ return path
23
+
24
+
25
+ @router.get("/api/repos/{repo_id}/graph", response_model=GraphResponse)
26
+ def api_graph(
27
+ repo_id: str,
28
+ before: str | None = None,
29
+ limit: int = Query(GRAPH_PAGE_SIZE, ge=1, le=10 * GRAPH_PAGE_SIZE),
30
+ ) -> GraphResponse:
31
+ path = _resolve_repo_path(repo_id)
32
+ try:
33
+ return GraphResponse(**get_graph(path, limit=limit, before=before)._asdict())
34
+ except HistoryChangedError as exc:
35
+ # The cursor commit is gone (history rewritten): the client should reload from the first page.
36
+ raise HTTPException(status_code=409, detail="graph history changed") from exc
37
+
38
+
39
+ @router.get("/api/repos/{repo_id}/status", response_model=RepoStatusResponse)
40
+ def api_repo_status(repo_id: str) -> RepoStatusResponse:
41
+ path = _resolve_repo_path(repo_id)
42
+ return get_repo_status(path)
43
+
44
+
45
+ @router.get("/api/repos/{repo_id}/commits/{sha}", response_model=CommitDetail)
46
+ def api_commit_detail(repo_id: str, sha: str) -> CommitDetail:
47
+ path = _resolve_repo_path(repo_id)
48
+ try:
49
+ return get_commit_detail(path, sha)
50
+ except Exception as exc: # noqa: BLE001 - surfaced as a 404 either way
51
+ raise HTTPException(status_code=404, detail="commit not found") from exc
52
+
53
+
54
+ @router.get("/api/repos/{repo_id}/commits/{sha}/diff", response_model=FileDiff)
55
+ def api_commit_file_diff(
56
+ repo_id: str,
57
+ sha: str,
58
+ path: str,
59
+ old_path: str | None = None,
60
+ full: bool = False,
61
+ ) -> FileDiff:
62
+ repo_path = _resolve_repo_path(repo_id)
63
+ try:
64
+ return get_commit_file_diff(repo_path, sha, path, old_path, full)
65
+ except Exception as exc: # noqa: BLE001 - surfaced as a 404 either way
66
+ raise HTTPException(status_code=404, detail="diff not found") from exc
67
+
68
+
69
+ @router.get("/api/repos/{repo_id}/diff", response_model=FileDiff)
70
+ def api_working_file_diff(
71
+ repo_id: str, path: str, old_path: str | None = None, full: bool = False
72
+ ) -> FileDiff:
73
+ repo_path = _resolve_repo_path(repo_id)
74
+ try:
75
+ return get_working_file_diff(repo_path, path, old_path, full)
76
+ except Exception as exc: # noqa: BLE001 - surfaced as a 404 either way
77
+ raise HTTPException(status_code=404, detail="diff not found") from exc
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter
4
+
5
+ from .. import config
6
+ from ..repo_discovery import get_scan_progress, list_repos
7
+ from ..schemas import BrowseDirectoryResponse, RepoScanProgress, RepoSummary
8
+ from ..ui.folder_picker import browse_directory
9
+
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ @router.get("/api/repos", response_model=list[RepoSummary])
15
+ def api_list_repos() -> list[RepoSummary]:
16
+ return list_repos(config.load_repo_paths())
17
+
18
+
19
+ @router.get("/api/repos/scan-progress", response_model=RepoScanProgress)
20
+ def api_repos_scan_progress() -> RepoScanProgress:
21
+ return RepoScanProgress(**get_scan_progress())
22
+
23
+
24
+ @router.get("/api/browse", response_model=BrowseDirectoryResponse)
25
+ def api_browse(path: str | None = None) -> BrowseDirectoryResponse:
26
+ return browse_directory(path)
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
6
+
7
+ from .. import config
8
+ from ..repo_discovery import resolve_repo_path
9
+ from ..terminal import run_terminal_session
10
+
11
+
12
+ def create_terminal_router(root_path: Path) -> APIRouter:
13
+ router = APIRouter()
14
+
15
+ @router.websocket("/ws/terminal")
16
+ async def ws_terminal(websocket: WebSocket) -> None:
17
+ repo_id = websocket.query_params.get("repo")
18
+ cwd = root_path
19
+ if repo_id:
20
+ resolved = resolve_repo_path(config.load_repo_paths(), repo_id)
21
+ if resolved is None:
22
+ await websocket.close(code=1008)
23
+ return
24
+ cwd = resolved
25
+ await websocket.accept()
26
+ try:
27
+ await run_terminal_session(websocket, cwd=cwd)
28
+ except WebSocketDisconnect:
29
+ pass
30
+
31
+ return router
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import FileResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+
10
+ from . import config
11
+ from .api.agents import router as agents_router
12
+ from .api.ci import router as ci_router
13
+ from .api.config import create_config_router
14
+ from .api.git import router as git_router
15
+ from .api.repos import router as repos_router
16
+ from .api.terminal import create_terminal_router
17
+
18
+
19
+ def create_app(root_path: Path, frontend_dist: Path | None = None) -> FastAPI:
20
+ app = FastAPI(title="git-juggler")
21
+ app.state.root_path = root_path
22
+ config.ensure_seeded(root_path)
23
+
24
+ # Only needed for local dev, when the Vite dev server (a different origin)
25
+ # talks to this API directly instead of through its proxy.
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
29
+ allow_methods=["*"],
30
+ allow_headers=["*"],
31
+ )
32
+ app.include_router(repos_router)
33
+ app.include_router(git_router)
34
+ app.include_router(ci_router)
35
+ app.include_router(agents_router)
36
+ app.include_router(create_config_router(root_path))
37
+ app.include_router(create_terminal_router(root_path))
38
+
39
+ if frontend_dist and frontend_dist.exists():
40
+ assets_dir = frontend_dist / "assets"
41
+ if assets_dir.exists():
42
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
43
+
44
+ @app.get("/{full_path:path}")
45
+ def spa(full_path: str) -> FileResponse:
46
+ candidate = frontend_dist / full_path
47
+ if full_path and candidate.is_file():
48
+ return FileResponse(candidate)
49
+ return FileResponse(frontend_dist / "index.html")
50
+
51
+ return app
@@ -6,7 +6,7 @@ from pathlib import Path
6
6
  from typing import Protocol
7
7
 
8
8
  from . import github_actions, jenkins
9
- from .schemas import ActivePipeline, CiRunInfo, CiStage, RepoSummary
9
+ from ..schemas import ActivePipeline, CiRunInfo, CiStage, RepoSummary
10
10
 
11
11
 
12
12
  class CiProvider(Protocol):
@@ -120,6 +120,13 @@ def _active_runs_for_repo(repo: RepoSummary, github_config: dict | None, jenkins
120
120
  return [ActivePipeline(repo_id=repo.id, repo_name=repo.name, run=run) for run in runs]
121
121
 
122
122
 
123
+ def _pipeline_key(pipeline: ActivePipeline) -> tuple[str, str]:
124
+ run = pipeline.run
125
+ if run.run_id:
126
+ return (run.provider, run.run_id)
127
+ return (run.provider, run.url)
128
+
129
+
123
130
  def get_active_pipelines(
124
131
  repos: list[RepoSummary],
125
132
  github_config: dict | None,
@@ -132,4 +139,8 @@ def get_active_pipelines(
132
139
  with ThreadPoolExecutor(max_workers=min(8, len(repos))) as pool:
133
140
  per_repo = pool.map(lambda repo: _active_runs_for_repo(repo, github_config, jenkins_config), repos)
134
141
  pipelines = [item for group in per_repo for item in group]
135
- return sorted(pipelines, key=lambda item: item.run.created_at or "", reverse=True)
142
+ deduped_by_key: dict[tuple[str, str], ActivePipeline] = {}
143
+ for pipeline in pipelines:
144
+ deduped_by_key.setdefault(_pipeline_key(pipeline), pipeline)
145
+ deduped = list(deduped_by_key.values())
146
+ return sorted(deduped, key=lambda item: item.run.created_at or "", reverse=True)