@thxgg/steward 0.1.0

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 (154) hide show
  1. package/.env.example +7 -0
  2. package/LICENSE +21 -0
  3. package/README.md +175 -0
  4. package/app/app.vue +14 -0
  5. package/app/assets/css/main.css +129 -0
  6. package/app/components/CommandPalette.vue +182 -0
  7. package/app/components/ShortcutsHelp.vue +85 -0
  8. package/app/components/git/ChangesMinimap.vue +143 -0
  9. package/app/components/git/CommitList.vue +224 -0
  10. package/app/components/git/DiffPanel.vue +402 -0
  11. package/app/components/git/DiffViewer.vue +803 -0
  12. package/app/components/layout/RepoSelector.vue +358 -0
  13. package/app/components/layout/Sidebar.vue +91 -0
  14. package/app/components/prd/Meta.vue +69 -0
  15. package/app/components/prd/Viewer.vue +285 -0
  16. package/app/components/tasks/Board.vue +86 -0
  17. package/app/components/tasks/Card.vue +108 -0
  18. package/app/components/tasks/Column.vue +108 -0
  19. package/app/components/tasks/Detail.vue +291 -0
  20. package/app/components/ui/badge/Badge.vue +26 -0
  21. package/app/components/ui/badge/index.ts +26 -0
  22. package/app/components/ui/button/Button.vue +29 -0
  23. package/app/components/ui/button/index.ts +38 -0
  24. package/app/components/ui/card/Card.vue +22 -0
  25. package/app/components/ui/card/CardAction.vue +17 -0
  26. package/app/components/ui/card/CardContent.vue +17 -0
  27. package/app/components/ui/card/CardDescription.vue +17 -0
  28. package/app/components/ui/card/CardFooter.vue +17 -0
  29. package/app/components/ui/card/CardHeader.vue +17 -0
  30. package/app/components/ui/card/CardTitle.vue +17 -0
  31. package/app/components/ui/card/index.ts +7 -0
  32. package/app/components/ui/combobox/Combobox.vue +19 -0
  33. package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
  34. package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
  35. package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
  36. package/app/components/ui/combobox/ComboboxInput.vue +42 -0
  37. package/app/components/ui/combobox/ComboboxItem.vue +24 -0
  38. package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
  39. package/app/components/ui/combobox/ComboboxList.vue +33 -0
  40. package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
  41. package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
  42. package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
  43. package/app/components/ui/combobox/index.ts +13 -0
  44. package/app/components/ui/command/Command.vue +103 -0
  45. package/app/components/ui/command/CommandDialog.vue +33 -0
  46. package/app/components/ui/command/CommandEmpty.vue +27 -0
  47. package/app/components/ui/command/CommandGroup.vue +45 -0
  48. package/app/components/ui/command/CommandInput.vue +54 -0
  49. package/app/components/ui/command/CommandItem.vue +76 -0
  50. package/app/components/ui/command/CommandList.vue +25 -0
  51. package/app/components/ui/command/CommandSeparator.vue +21 -0
  52. package/app/components/ui/command/CommandShortcut.vue +17 -0
  53. package/app/components/ui/command/index.ts +25 -0
  54. package/app/components/ui/dialog/Dialog.vue +19 -0
  55. package/app/components/ui/dialog/DialogClose.vue +15 -0
  56. package/app/components/ui/dialog/DialogContent.vue +53 -0
  57. package/app/components/ui/dialog/DialogDescription.vue +23 -0
  58. package/app/components/ui/dialog/DialogFooter.vue +15 -0
  59. package/app/components/ui/dialog/DialogHeader.vue +17 -0
  60. package/app/components/ui/dialog/DialogOverlay.vue +21 -0
  61. package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
  62. package/app/components/ui/dialog/DialogTitle.vue +23 -0
  63. package/app/components/ui/dialog/DialogTrigger.vue +15 -0
  64. package/app/components/ui/dialog/index.ts +10 -0
  65. package/app/components/ui/input/Input.vue +33 -0
  66. package/app/components/ui/input/index.ts +1 -0
  67. package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
  68. package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
  69. package/app/components/ui/scroll-area/index.ts +2 -0
  70. package/app/components/ui/separator/Separator.vue +29 -0
  71. package/app/components/ui/separator/index.ts +1 -0
  72. package/app/components/ui/sheet/Sheet.vue +19 -0
  73. package/app/components/ui/sheet/SheetClose.vue +15 -0
  74. package/app/components/ui/sheet/SheetContent.vue +62 -0
  75. package/app/components/ui/sheet/SheetDescription.vue +21 -0
  76. package/app/components/ui/sheet/SheetFooter.vue +16 -0
  77. package/app/components/ui/sheet/SheetHeader.vue +15 -0
  78. package/app/components/ui/sheet/SheetOverlay.vue +21 -0
  79. package/app/components/ui/sheet/SheetTitle.vue +21 -0
  80. package/app/components/ui/sheet/SheetTrigger.vue +15 -0
  81. package/app/components/ui/sheet/index.ts +8 -0
  82. package/app/components/ui/tabs/Tabs.vue +24 -0
  83. package/app/components/ui/tabs/TabsContent.vue +21 -0
  84. package/app/components/ui/tabs/TabsList.vue +24 -0
  85. package/app/components/ui/tabs/TabsTrigger.vue +26 -0
  86. package/app/components/ui/tabs/index.ts +4 -0
  87. package/app/components/ui/tooltip/Tooltip.vue +19 -0
  88. package/app/components/ui/tooltip/TooltipContent.vue +34 -0
  89. package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
  90. package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
  91. package/app/components/ui/tooltip/index.ts +4 -0
  92. package/app/composables/useFileWatch.ts +78 -0
  93. package/app/composables/useGit.ts +180 -0
  94. package/app/composables/useKeyboard.ts +180 -0
  95. package/app/composables/usePrd.ts +86 -0
  96. package/app/composables/useRepos.ts +108 -0
  97. package/app/composables/useThemeMode.ts +38 -0
  98. package/app/composables/useToast.ts +31 -0
  99. package/app/layouts/default.vue +197 -0
  100. package/app/lib/utils.ts +7 -0
  101. package/app/pages/[repo]/[prd].vue +263 -0
  102. package/app/pages/index.vue +257 -0
  103. package/app/types/git.ts +81 -0
  104. package/app/types/index.ts +29 -0
  105. package/app/types/prd.ts +49 -0
  106. package/app/types/repo.ts +37 -0
  107. package/app/types/task.ts +134 -0
  108. package/bin/prd +21 -0
  109. package/components.json +21 -0
  110. package/dist/app/types/git.js +1 -0
  111. package/dist/app/types/prd.js +1 -0
  112. package/dist/app/types/repo.js +1 -0
  113. package/dist/app/types/task.js +1 -0
  114. package/dist/host/src/api/git.js +96 -0
  115. package/dist/host/src/api/index.js +4 -0
  116. package/dist/host/src/api/prds.js +195 -0
  117. package/dist/host/src/api/repos.js +47 -0
  118. package/dist/host/src/api/state.js +63 -0
  119. package/dist/host/src/executor.js +109 -0
  120. package/dist/host/src/index.js +95 -0
  121. package/dist/host/src/mcp.js +62 -0
  122. package/dist/host/src/ui.js +64 -0
  123. package/dist/server/utils/db.js +125 -0
  124. package/dist/server/utils/git.js +396 -0
  125. package/dist/server/utils/prd-state.js +229 -0
  126. package/dist/server/utils/repos.js +256 -0
  127. package/docs/MCP.md +180 -0
  128. package/nuxt.config.ts +34 -0
  129. package/package.json +88 -0
  130. package/public/favicon.ico +0 -0
  131. package/public/robots.txt +1 -0
  132. package/server/api/browse.get.ts +52 -0
  133. package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
  134. package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
  135. package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
  136. package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
  137. package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
  138. package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
  139. package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
  140. package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
  141. package/server/api/repos/[repoId]/prds.get.ts +85 -0
  142. package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
  143. package/server/api/repos/[repoId].delete.ts +27 -0
  144. package/server/api/repos/index.get.ts +5 -0
  145. package/server/api/repos/index.post.ts +39 -0
  146. package/server/api/watch.get.ts +63 -0
  147. package/server/plugins/migrate-legacy-state.ts +19 -0
  148. package/server/tsconfig.json +3 -0
  149. package/server/utils/db.ts +169 -0
  150. package/server/utils/git.ts +478 -0
  151. package/server/utils/prd-state.ts +335 -0
  152. package/server/utils/repos.ts +322 -0
  153. package/server/utils/watcher.ts +179 -0
  154. package/tsconfig.json +4 -0
@@ -0,0 +1,21 @@
1
+ <script setup lang="ts">
2
+ import type { TabsContentProps } from "reka-ui"
3
+ import type { HTMLAttributes } from "vue"
4
+ import { reactiveOmit } from "@vueuse/core"
5
+ import { TabsContent } from "reka-ui"
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const props = defineProps<TabsContentProps & { class?: HTMLAttributes["class"] }>()
9
+
10
+ const delegatedProps = reactiveOmit(props, "class")
11
+ </script>
12
+
13
+ <template>
14
+ <TabsContent
15
+ data-slot="tabs-content"
16
+ :class="cn('flex-1 outline-none', props.class)"
17
+ v-bind="delegatedProps"
18
+ >
19
+ <slot />
20
+ </TabsContent>
21
+ </template>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ import type { TabsListProps } from "reka-ui"
3
+ import type { HTMLAttributes } from "vue"
4
+ import { reactiveOmit } from "@vueuse/core"
5
+ import { TabsList } from "reka-ui"
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const props = defineProps<TabsListProps & { class?: HTMLAttributes["class"] }>()
9
+
10
+ const delegatedProps = reactiveOmit(props, "class")
11
+ </script>
12
+
13
+ <template>
14
+ <TabsList
15
+ data-slot="tabs-list"
16
+ v-bind="delegatedProps"
17
+ :class="cn(
18
+ 'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
19
+ props.class,
20
+ )"
21
+ >
22
+ <slot />
23
+ </TabsList>
24
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ import type { TabsTriggerProps } from "reka-ui"
3
+ import type { HTMLAttributes } from "vue"
4
+ import { reactiveOmit } from "@vueuse/core"
5
+ import { TabsTrigger, useForwardProps } from "reka-ui"
6
+ import { cn } from "@/lib/utils"
7
+
8
+ const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes["class"] }>()
9
+
10
+ const delegatedProps = reactiveOmit(props, "class")
11
+
12
+ const forwardedProps = useForwardProps(delegatedProps)
13
+ </script>
14
+
15
+ <template>
16
+ <TabsTrigger
17
+ data-slot="tabs-trigger"
18
+ :class="cn(
19
+ 'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
20
+ props.class,
21
+ )"
22
+ v-bind="forwardedProps"
23
+ >
24
+ <slot />
25
+ </TabsTrigger>
26
+ </template>
@@ -0,0 +1,4 @@
1
+ export { default as Tabs } from "./Tabs.vue"
2
+ export { default as TabsContent } from "./TabsContent.vue"
3
+ export { default as TabsList } from "./TabsList.vue"
4
+ export { default as TabsTrigger } from "./TabsTrigger.vue"
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ import type { TooltipRootEmits, TooltipRootProps } from "reka-ui"
3
+ import { TooltipRoot, useForwardPropsEmits } from "reka-ui"
4
+
5
+ const props = defineProps<TooltipRootProps>()
6
+ const emits = defineEmits<TooltipRootEmits>()
7
+
8
+ const forwarded = useForwardPropsEmits(props, emits)
9
+ </script>
10
+
11
+ <template>
12
+ <TooltipRoot
13
+ v-slot="slotProps"
14
+ data-slot="tooltip"
15
+ v-bind="forwarded"
16
+ >
17
+ <slot v-bind="slotProps" />
18
+ </TooltipRoot>
19
+ </template>
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ import type { TooltipContentEmits, TooltipContentProps } from "reka-ui"
3
+ import type { HTMLAttributes } from "vue"
4
+ import { reactiveOmit } from "@vueuse/core"
5
+ import { TooltipArrow, TooltipContent, TooltipPortal, useForwardPropsEmits } from "reka-ui"
6
+ import { cn } from "@/lib/utils"
7
+
8
+ defineOptions({
9
+ inheritAttrs: false,
10
+ })
11
+
12
+ const props = withDefaults(defineProps<TooltipContentProps & { class?: HTMLAttributes["class"] }>(), {
13
+ sideOffset: 4,
14
+ })
15
+
16
+ const emits = defineEmits<TooltipContentEmits>()
17
+
18
+ const delegatedProps = reactiveOmit(props, "class")
19
+ const forwarded = useForwardPropsEmits(delegatedProps, emits)
20
+ </script>
21
+
22
+ <template>
23
+ <TooltipPortal>
24
+ <TooltipContent
25
+ data-slot="tooltip-content"
26
+ v-bind="{ ...forwarded, ...$attrs }"
27
+ :class="cn('bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance', props.class)"
28
+ >
29
+ <slot />
30
+
31
+ <TooltipArrow class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
32
+ </TooltipContent>
33
+ </TooltipPortal>
34
+ </template>
@@ -0,0 +1,14 @@
1
+ <script setup lang="ts">
2
+ import type { TooltipProviderProps } from "reka-ui"
3
+ import { TooltipProvider } from "reka-ui"
4
+
5
+ const props = withDefaults(defineProps<TooltipProviderProps>(), {
6
+ delayDuration: 0,
7
+ })
8
+ </script>
9
+
10
+ <template>
11
+ <TooltipProvider v-bind="props">
12
+ <slot />
13
+ </TooltipProvider>
14
+ </template>
@@ -0,0 +1,15 @@
1
+ <script setup lang="ts">
2
+ import type { TooltipTriggerProps } from "reka-ui"
3
+ import { TooltipTrigger } from "reka-ui"
4
+
5
+ const props = defineProps<TooltipTriggerProps>()
6
+ </script>
7
+
8
+ <template>
9
+ <TooltipTrigger
10
+ data-slot="tooltip-trigger"
11
+ v-bind="props"
12
+ >
13
+ <slot />
14
+ </TooltipTrigger>
15
+ </template>
@@ -0,0 +1,4 @@
1
+ export { default as Tooltip } from "./Tooltip.vue"
2
+ export { default as TooltipContent } from "./TooltipContent.vue"
3
+ export { default as TooltipProvider } from "./TooltipProvider.vue"
4
+ export { default as TooltipTrigger } from "./TooltipTrigger.vue"
@@ -0,0 +1,78 @@
1
+ type FileChangeEvent = {
2
+ type: 'change' | 'add' | 'unlink' | 'connected'
3
+ path?: string
4
+ repoId?: string
5
+ category?: 'prd' | 'tasks' | 'progress'
6
+ }
7
+
8
+ type FileWatchCallback = (event: FileChangeEvent) => void
9
+
10
+ export function useFileWatch(callback: FileWatchCallback) {
11
+ const eventSource = ref<EventSource | null>(null)
12
+ const isConnected = ref(false)
13
+ const error = ref<string | null>(null)
14
+
15
+ function connect() {
16
+ if (!import.meta.client) return
17
+ if (eventSource.value) return // Already connected
18
+
19
+ try {
20
+ const es = new EventSource('/api/watch')
21
+
22
+ es.onopen = () => {
23
+ isConnected.value = true
24
+ error.value = null
25
+ }
26
+
27
+ es.onmessage = (e) => {
28
+ try {
29
+ const event = JSON.parse(e.data) as FileChangeEvent
30
+ callback(event)
31
+ } catch {
32
+ // Ignore malformed events
33
+ }
34
+ }
35
+
36
+ es.onerror = () => {
37
+ isConnected.value = false
38
+ error.value = 'Connection lost'
39
+
40
+ // Attempt reconnect after 5 seconds
41
+ setTimeout(() => {
42
+ if (eventSource.value === es) {
43
+ eventSource.value = null
44
+ connect()
45
+ }
46
+ }, 5000)
47
+ }
48
+
49
+ eventSource.value = es
50
+ } catch {
51
+ error.value = 'Failed to connect'
52
+ }
53
+ }
54
+
55
+ function disconnect() {
56
+ if (eventSource.value) {
57
+ eventSource.value.close()
58
+ eventSource.value = null
59
+ isConnected.value = false
60
+ }
61
+ }
62
+
63
+ // Auto-connect on mount, disconnect on unmount
64
+ onMounted(() => {
65
+ connect()
66
+ })
67
+
68
+ onUnmounted(() => {
69
+ disconnect()
70
+ })
71
+
72
+ return {
73
+ isConnected,
74
+ error,
75
+ connect,
76
+ disconnect
77
+ }
78
+ }
@@ -0,0 +1,180 @@
1
+ import type { GitCommit, FileDiff, DiffHunk } from '~/types/git'
2
+
3
+ export interface FetchCommitsResult {
4
+ commits: GitCommit[]
5
+ failedShas: string[]
6
+ }
7
+
8
+ export function useGit() {
9
+ const { showError } = useToast()
10
+
11
+ // Loading states
12
+ const isLoadingCommits = ref(false)
13
+ const isLoadingDiff = ref(false)
14
+ const isLoadingFileDiff = ref(false)
15
+ const isLoadingFileContent = ref(false)
16
+
17
+ /**
18
+ * Fetch commit details for an array of SHAs
19
+ * @param repoId - Repository ID
20
+ * @param shas - Array of commit SHAs
21
+ * @param repoPath - Optional relative path to git repo (for pseudo-monorepos)
22
+ * @returns Object with fetched commits and SHAs that failed to load
23
+ */
24
+ async function fetchCommits(repoId: string, shas: string[], repoPath?: string): Promise<FetchCommitsResult> {
25
+ if (!repoId || shas.length === 0) {
26
+ return { commits: [], failedShas: [] }
27
+ }
28
+
29
+ isLoadingCommits.value = true
30
+ try {
31
+ const query: Record<string, string> = { shas: shas.join(',') }
32
+ if (repoPath) {
33
+ query.repo = repoPath
34
+ }
35
+
36
+ const commits = await $fetch<GitCommit[]>(
37
+ `/api/repos/${repoId}/git/commits`,
38
+ { query }
39
+ )
40
+
41
+ // Determine which SHAs weren't returned (partial failures on server)
42
+ // Use shortSha for comparison since requests may use abbreviated SHAs
43
+ const returnedShortShas = commits.map(c => c.shortSha)
44
+ const failedShas = shas.filter(sha => {
45
+ // Check if any returned shortSha matches the requested SHA (prefix matching)
46
+ return !returnedShortShas.some(shortSha => shortSha.startsWith(sha) || sha.startsWith(shortSha))
47
+ })
48
+
49
+ return { commits, failedShas }
50
+ } catch (error) {
51
+ const message = error instanceof Error ? error.message : 'Unknown error'
52
+ showError('Failed to fetch commits', message)
53
+ // All requested SHAs failed
54
+ return { commits: [], failedShas: shas }
55
+ } finally {
56
+ isLoadingCommits.value = false
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Fetch file list with stats for a commit
62
+ * @param repoId - Repository ID
63
+ * @param commitSha - Commit SHA
64
+ * @param repoPath - Optional relative path to git repo (for pseudo-monorepos)
65
+ */
66
+ async function fetchDiff(repoId: string, commitSha: string, repoPath?: string): Promise<FileDiff[]> {
67
+ if (!repoId || !commitSha) {
68
+ return []
69
+ }
70
+
71
+ isLoadingDiff.value = true
72
+ try {
73
+ const query: Record<string, string> = { commit: commitSha }
74
+ if (repoPath) {
75
+ query.repo = repoPath
76
+ }
77
+
78
+ const files = await $fetch<FileDiff[]>(
79
+ `/api/repos/${repoId}/git/diff`,
80
+ { query }
81
+ )
82
+ return files
83
+ } catch (error) {
84
+ const message = error instanceof Error ? error.message : 'Unknown error'
85
+ showError('Failed to fetch diff', message)
86
+ return []
87
+ } finally {
88
+ isLoadingDiff.value = false
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Fetch diff hunks for a specific file in a commit
94
+ * @param repoId - Repository ID
95
+ * @param commitSha - Commit SHA
96
+ * @param filePath - Path to the file
97
+ * @param repoPath - Optional relative path to git repo (for pseudo-monorepos)
98
+ */
99
+ async function fetchFileDiff(
100
+ repoId: string,
101
+ commitSha: string,
102
+ filePath: string,
103
+ repoPath?: string
104
+ ): Promise<DiffHunk[]> {
105
+ if (!repoId || !commitSha || !filePath) {
106
+ return []
107
+ }
108
+
109
+ isLoadingFileDiff.value = true
110
+ try {
111
+ const query: Record<string, string> = { commit: commitSha, file: filePath }
112
+ if (repoPath) {
113
+ query.repo = repoPath
114
+ }
115
+
116
+ const hunks = await $fetch<DiffHunk[]>(
117
+ `/api/repos/${repoId}/git/file-diff`,
118
+ { query }
119
+ )
120
+ return hunks
121
+ } catch (error) {
122
+ const message = error instanceof Error ? error.message : 'Unknown error'
123
+ showError('Failed to fetch file diff', message)
124
+ return []
125
+ } finally {
126
+ isLoadingFileDiff.value = false
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Fetch file content at a specific commit
132
+ * @param repoId - Repository ID
133
+ * @param commitSha - Commit SHA
134
+ * @param filePath - Path to the file
135
+ * @param repoPath - Optional relative path to git repo (for pseudo-monorepos)
136
+ */
137
+ async function fetchFileContent(
138
+ repoId: string,
139
+ commitSha: string,
140
+ filePath: string,
141
+ repoPath?: string
142
+ ): Promise<string | null> {
143
+ if (!repoId || !commitSha || !filePath) {
144
+ return null
145
+ }
146
+
147
+ isLoadingFileContent.value = true
148
+ try {
149
+ const query: Record<string, string> = { commit: commitSha, file: filePath }
150
+ if (repoPath) {
151
+ query.repo = repoPath
152
+ }
153
+
154
+ const result = await $fetch<{ content: string }>(
155
+ `/api/repos/${repoId}/git/file-content`,
156
+ { query }
157
+ )
158
+ return result.content
159
+ } catch (error) {
160
+ const message = error instanceof Error ? error.message : 'Unknown error'
161
+ showError('Failed to fetch file content', message)
162
+ return null
163
+ } finally {
164
+ isLoadingFileContent.value = false
165
+ }
166
+ }
167
+
168
+ return {
169
+ // Functions
170
+ fetchCommits,
171
+ fetchDiff,
172
+ fetchFileDiff,
173
+ fetchFileContent,
174
+ // Loading states
175
+ isLoadingCommits: readonly(isLoadingCommits),
176
+ isLoadingDiff: readonly(isLoadingDiff),
177
+ isLoadingFileDiff: readonly(isLoadingFileDiff),
178
+ isLoadingFileContent: readonly(isLoadingFileContent),
179
+ }
180
+ }
@@ -0,0 +1,180 @@
1
+ import { onMounted, onUnmounted } from 'vue'
2
+
3
+ export interface KeyboardShortcut {
4
+ keys: string
5
+ handler: () => void
6
+ description?: string
7
+ }
8
+
9
+ /**
10
+ * Check if the currently focused element is an input field
11
+ * where keyboard shortcuts should be suppressed
12
+ */
13
+ function isTypingInInput(): boolean {
14
+ if (!import.meta.client) return false
15
+
16
+ const activeElement = document.activeElement
17
+ if (!activeElement) return false
18
+
19
+ // Check for input, textarea, or contenteditable elements
20
+ const tagName = activeElement.tagName.toLowerCase()
21
+ if (tagName === 'input' || tagName === 'textarea') {
22
+ return true
23
+ }
24
+
25
+ // Check for contenteditable
26
+ if (activeElement.getAttribute('contenteditable') === 'true') {
27
+ return true
28
+ }
29
+
30
+ // Check for role="textbox"
31
+ if (activeElement.getAttribute('role') === 'textbox') {
32
+ return true
33
+ }
34
+
35
+ return false
36
+ }
37
+
38
+ /**
39
+ * Parse a key combo string into its components
40
+ * e.g., 'Meta+Shift+a' -> { meta: true, ctrl: false, shift: true, alt: false, key: 'a' }
41
+ */
42
+ function parseKeyCombo(combo: string): { meta: boolean; ctrl: boolean; shift: boolean; alt: boolean; key: string } {
43
+ const parts = combo.toLowerCase().split('+')
44
+ const key = parts.pop() || ''
45
+
46
+ return {
47
+ meta: parts.includes('meta'),
48
+ ctrl: parts.includes('ctrl'),
49
+ shift: parts.includes('shift'),
50
+ alt: parts.includes('alt'),
51
+ key
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Check if a keyboard event matches a key combo
57
+ */
58
+ function eventMatchesCombo(event: KeyboardEvent, combo: ReturnType<typeof parseKeyCombo>): boolean {
59
+ const eventKey = event.key.toLowerCase()
60
+
61
+ // Handle special keys
62
+ let keyMatches = eventKey === combo.key
63
+ if (combo.key === '\\') {
64
+ keyMatches = eventKey === '\\' || event.code === 'Backslash'
65
+ }
66
+ if (combo.key === '/') {
67
+ keyMatches = eventKey === '/' || event.code === 'Slash'
68
+ }
69
+ if (combo.key === '.') {
70
+ keyMatches = eventKey === '.' || event.code === 'Period'
71
+ }
72
+ if (combo.key === ',') {
73
+ keyMatches = eventKey === ',' || event.code === 'Comma'
74
+ }
75
+
76
+ return (
77
+ keyMatches &&
78
+ event.metaKey === combo.meta &&
79
+ event.ctrlKey === combo.ctrl &&
80
+ event.shiftKey === combo.shift &&
81
+ event.altKey === combo.alt
82
+ )
83
+ }
84
+
85
+ interface RegisteredShortcut {
86
+ combo: ReturnType<typeof parseKeyCombo>
87
+ handler: () => void
88
+ allowInInput: boolean
89
+ }
90
+
91
+ // Global registry of shortcuts
92
+ const shortcuts: RegisteredShortcut[] = []
93
+
94
+ let listenerAttached = false
95
+
96
+ function handleKeyDown(event: KeyboardEvent) {
97
+ for (const shortcut of shortcuts) {
98
+ if (eventMatchesCombo(event, shortcut.combo)) {
99
+ // Skip if typing in input unless explicitly allowed
100
+ if (!shortcut.allowInInput && isTypingInInput()) {
101
+ continue
102
+ }
103
+
104
+ // Prevent browser default behavior
105
+ event.preventDefault()
106
+ event.stopPropagation()
107
+
108
+ // Call the handler
109
+ shortcut.handler()
110
+ return
111
+ }
112
+ }
113
+ }
114
+
115
+ function ensureListener() {
116
+ if (!import.meta.client || listenerAttached) return
117
+
118
+ document.addEventListener('keydown', handleKeyDown, { capture: true })
119
+ listenerAttached = true
120
+ }
121
+
122
+ /**
123
+ * Composable for handling global keyboard shortcuts.
124
+ * Prevents default browser behavior when shortcuts are triggered.
125
+ *
126
+ * Shortcuts are automatically suppressed when the user is typing in an input field.
127
+ */
128
+ export function useKeyboard() {
129
+ onMounted(() => {
130
+ ensureListener()
131
+ })
132
+
133
+ /**
134
+ * Register a keyboard shortcut that fires when the specified key combo is pressed.
135
+ * The handler will NOT fire if the user is typing in an input field.
136
+ * Browser default behavior is automatically prevented.
137
+ *
138
+ * @param keyCombo - Key combination string (e.g., 'Meta+k', 'Ctrl+Shift+a')
139
+ * @param handler - Function to call when shortcut is triggered
140
+ * @param options - Additional options
141
+ */
142
+ function onShortcut(
143
+ keyCombo: string,
144
+ handler: () => void,
145
+ options?: { allowInInput?: boolean }
146
+ ) {
147
+ const combo = parseKeyCombo(keyCombo)
148
+
149
+ const shortcut: RegisteredShortcut = {
150
+ combo,
151
+ handler,
152
+ allowInInput: options?.allowInInput ?? false
153
+ }
154
+
155
+ shortcuts.push(shortcut)
156
+
157
+ // Ensure listener is attached
158
+ ensureListener()
159
+
160
+ // Cleanup on unmount
161
+ onUnmounted(() => {
162
+ const index = shortcuts.indexOf(shortcut)
163
+ if (index > -1) {
164
+ shortcuts.splice(index, 1)
165
+ }
166
+ })
167
+ }
168
+
169
+ /**
170
+ * Check if the user is currently focused on an input element
171
+ */
172
+ function isInputFocused(): boolean {
173
+ return isTypingInInput()
174
+ }
175
+
176
+ return {
177
+ onShortcut,
178
+ isInputFocused
179
+ }
180
+ }
@@ -0,0 +1,86 @@
1
+ import type { PrdListItem, PrdDocument } from '~/types/prd'
2
+ import type { TasksFile, ProgressFile, CommitRef } from '~/types/task'
3
+
4
+ export function usePrd() {
5
+ const { currentRepoId } = useRepos()
6
+ const { showError } = useToast()
7
+
8
+ // PRD list for current repo - refetches when currentRepoId changes
9
+ const prdsUrl = computed(() =>
10
+ currentRepoId.value ? `/api/repos/${currentRepoId.value}/prds` : ''
11
+ )
12
+
13
+ const { data: prds, refresh: refreshPrds, status: prdsStatus, error: prdsError } = useFetch<PrdListItem[]>(
14
+ prdsUrl,
15
+ {
16
+ default: () => [],
17
+ immediate: false
18
+ }
19
+ )
20
+
21
+ // Show error toast when PRD list fetch fails
22
+ watch(prdsError, (err) => {
23
+ if (err) {
24
+ showError('Failed to load PRD list', 'The repository may be inaccessible.')
25
+ }
26
+ })
27
+
28
+ // Watch for repo changes and fetch PRDs
29
+ watch(currentRepoId, async (newId) => {
30
+ if (newId) {
31
+ await refreshPrds()
32
+ }
33
+ }, { immediate: true })
34
+
35
+ // Fetch a PRD document by slug
36
+ async function fetchDocument(slug: string): Promise<PrdDocument | null> {
37
+ if (!currentRepoId.value) return null
38
+ try {
39
+ return await $fetch<PrdDocument>(`/api/repos/${currentRepoId.value}/prd/${slug}`)
40
+ } catch {
41
+ return null
42
+ }
43
+ }
44
+
45
+ // Fetch tasks.json for a PRD
46
+ async function fetchTasks(slug: string): Promise<TasksFile | null> {
47
+ if (!currentRepoId.value) return null
48
+ try {
49
+ return await $fetch<TasksFile | null>(`/api/repos/${currentRepoId.value}/prd/${slug}/tasks`)
50
+ } catch {
51
+ return null
52
+ }
53
+ }
54
+
55
+ // Fetch progress.json for a PRD
56
+ async function fetchProgress(slug: string): Promise<ProgressFile | null> {
57
+ if (!currentRepoId.value) return null
58
+ try {
59
+ return await $fetch<ProgressFile | null>(`/api/repos/${currentRepoId.value}/prd/${slug}/progress`)
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ // Fetch resolved commits for a task (returns { sha, repo }[] format)
66
+ async function fetchTaskCommits(slug: string, taskId: string): Promise<CommitRef[]> {
67
+ if (!currentRepoId.value) return []
68
+ try {
69
+ return await $fetch<CommitRef[]>(
70
+ `/api/repos/${currentRepoId.value}/prd/${slug}/tasks/${taskId}/commits`
71
+ )
72
+ } catch {
73
+ return []
74
+ }
75
+ }
76
+
77
+ return {
78
+ prds,
79
+ prdsStatus,
80
+ refreshPrds,
81
+ fetchDocument,
82
+ fetchTasks,
83
+ fetchProgress,
84
+ fetchTaskCommits
85
+ }
86
+ }