@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.
- package/.env.example +7 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/app/app.vue +14 -0
- package/app/assets/css/main.css +129 -0
- package/app/components/CommandPalette.vue +182 -0
- package/app/components/ShortcutsHelp.vue +85 -0
- package/app/components/git/ChangesMinimap.vue +143 -0
- package/app/components/git/CommitList.vue +224 -0
- package/app/components/git/DiffPanel.vue +402 -0
- package/app/components/git/DiffViewer.vue +803 -0
- package/app/components/layout/RepoSelector.vue +358 -0
- package/app/components/layout/Sidebar.vue +91 -0
- package/app/components/prd/Meta.vue +69 -0
- package/app/components/prd/Viewer.vue +285 -0
- package/app/components/tasks/Board.vue +86 -0
- package/app/components/tasks/Card.vue +108 -0
- package/app/components/tasks/Column.vue +108 -0
- package/app/components/tasks/Detail.vue +291 -0
- package/app/components/ui/badge/Badge.vue +26 -0
- package/app/components/ui/badge/index.ts +26 -0
- package/app/components/ui/button/Button.vue +29 -0
- package/app/components/ui/button/index.ts +38 -0
- package/app/components/ui/card/Card.vue +22 -0
- package/app/components/ui/card/CardAction.vue +17 -0
- package/app/components/ui/card/CardContent.vue +17 -0
- package/app/components/ui/card/CardDescription.vue +17 -0
- package/app/components/ui/card/CardFooter.vue +17 -0
- package/app/components/ui/card/CardHeader.vue +17 -0
- package/app/components/ui/card/CardTitle.vue +17 -0
- package/app/components/ui/card/index.ts +7 -0
- package/app/components/ui/combobox/Combobox.vue +19 -0
- package/app/components/ui/combobox/ComboboxAnchor.vue +23 -0
- package/app/components/ui/combobox/ComboboxEmpty.vue +21 -0
- package/app/components/ui/combobox/ComboboxGroup.vue +27 -0
- package/app/components/ui/combobox/ComboboxInput.vue +42 -0
- package/app/components/ui/combobox/ComboboxItem.vue +24 -0
- package/app/components/ui/combobox/ComboboxItemIndicator.vue +23 -0
- package/app/components/ui/combobox/ComboboxList.vue +33 -0
- package/app/components/ui/combobox/ComboboxSeparator.vue +21 -0
- package/app/components/ui/combobox/ComboboxTrigger.vue +24 -0
- package/app/components/ui/combobox/ComboboxViewport.vue +23 -0
- package/app/components/ui/combobox/index.ts +13 -0
- package/app/components/ui/command/Command.vue +103 -0
- package/app/components/ui/command/CommandDialog.vue +33 -0
- package/app/components/ui/command/CommandEmpty.vue +27 -0
- package/app/components/ui/command/CommandGroup.vue +45 -0
- package/app/components/ui/command/CommandInput.vue +54 -0
- package/app/components/ui/command/CommandItem.vue +76 -0
- package/app/components/ui/command/CommandList.vue +25 -0
- package/app/components/ui/command/CommandSeparator.vue +21 -0
- package/app/components/ui/command/CommandShortcut.vue +17 -0
- package/app/components/ui/command/index.ts +25 -0
- package/app/components/ui/dialog/Dialog.vue +19 -0
- package/app/components/ui/dialog/DialogClose.vue +15 -0
- package/app/components/ui/dialog/DialogContent.vue +53 -0
- package/app/components/ui/dialog/DialogDescription.vue +23 -0
- package/app/components/ui/dialog/DialogFooter.vue +15 -0
- package/app/components/ui/dialog/DialogHeader.vue +17 -0
- package/app/components/ui/dialog/DialogOverlay.vue +21 -0
- package/app/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/app/components/ui/dialog/DialogTitle.vue +23 -0
- package/app/components/ui/dialog/DialogTrigger.vue +15 -0
- package/app/components/ui/dialog/index.ts +10 -0
- package/app/components/ui/input/Input.vue +33 -0
- package/app/components/ui/input/index.ts +1 -0
- package/app/components/ui/scroll-area/ScrollArea.vue +33 -0
- package/app/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/app/components/ui/scroll-area/index.ts +2 -0
- package/app/components/ui/separator/Separator.vue +29 -0
- package/app/components/ui/separator/index.ts +1 -0
- package/app/components/ui/sheet/Sheet.vue +19 -0
- package/app/components/ui/sheet/SheetClose.vue +15 -0
- package/app/components/ui/sheet/SheetContent.vue +62 -0
- package/app/components/ui/sheet/SheetDescription.vue +21 -0
- package/app/components/ui/sheet/SheetFooter.vue +16 -0
- package/app/components/ui/sheet/SheetHeader.vue +15 -0
- package/app/components/ui/sheet/SheetOverlay.vue +21 -0
- package/app/components/ui/sheet/SheetTitle.vue +21 -0
- package/app/components/ui/sheet/SheetTrigger.vue +15 -0
- package/app/components/ui/sheet/index.ts +8 -0
- package/app/components/ui/tabs/Tabs.vue +24 -0
- package/app/components/ui/tabs/TabsContent.vue +21 -0
- package/app/components/ui/tabs/TabsList.vue +24 -0
- package/app/components/ui/tabs/TabsTrigger.vue +26 -0
- package/app/components/ui/tabs/index.ts +4 -0
- package/app/components/ui/tooltip/Tooltip.vue +19 -0
- package/app/components/ui/tooltip/TooltipContent.vue +34 -0
- package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
- package/app/components/ui/tooltip/TooltipTrigger.vue +15 -0
- package/app/components/ui/tooltip/index.ts +4 -0
- package/app/composables/useFileWatch.ts +78 -0
- package/app/composables/useGit.ts +180 -0
- package/app/composables/useKeyboard.ts +180 -0
- package/app/composables/usePrd.ts +86 -0
- package/app/composables/useRepos.ts +108 -0
- package/app/composables/useThemeMode.ts +38 -0
- package/app/composables/useToast.ts +31 -0
- package/app/layouts/default.vue +197 -0
- package/app/lib/utils.ts +7 -0
- package/app/pages/[repo]/[prd].vue +263 -0
- package/app/pages/index.vue +257 -0
- package/app/types/git.ts +81 -0
- package/app/types/index.ts +29 -0
- package/app/types/prd.ts +49 -0
- package/app/types/repo.ts +37 -0
- package/app/types/task.ts +134 -0
- package/bin/prd +21 -0
- package/components.json +21 -0
- package/dist/app/types/git.js +1 -0
- package/dist/app/types/prd.js +1 -0
- package/dist/app/types/repo.js +1 -0
- package/dist/app/types/task.js +1 -0
- package/dist/host/src/api/git.js +96 -0
- package/dist/host/src/api/index.js +4 -0
- package/dist/host/src/api/prds.js +195 -0
- package/dist/host/src/api/repos.js +47 -0
- package/dist/host/src/api/state.js +63 -0
- package/dist/host/src/executor.js +109 -0
- package/dist/host/src/index.js +95 -0
- package/dist/host/src/mcp.js +62 -0
- package/dist/host/src/ui.js +64 -0
- package/dist/server/utils/db.js +125 -0
- package/dist/server/utils/git.js +396 -0
- package/dist/server/utils/prd-state.js +229 -0
- package/dist/server/utils/repos.js +256 -0
- package/docs/MCP.md +180 -0
- package/nuxt.config.ts +34 -0
- package/package.json +88 -0
- package/public/favicon.ico +0 -0
- package/public/robots.txt +1 -0
- package/server/api/browse.get.ts +52 -0
- package/server/api/repos/[repoId]/git/commits.get.ts +103 -0
- package/server/api/repos/[repoId]/git/diff.get.ts +77 -0
- package/server/api/repos/[repoId]/git/file-content.get.ts +66 -0
- package/server/api/repos/[repoId]/git/file-diff.get.ts +109 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/progress.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks/[taskId]/commits.get.ts +146 -0
- package/server/api/repos/[repoId]/prd/[prdSlug]/tasks.get.ts +36 -0
- package/server/api/repos/[repoId]/prd/[prdSlug].get.ts +97 -0
- package/server/api/repos/[repoId]/prds.get.ts +85 -0
- package/server/api/repos/[repoId]/refresh-git-repos.post.ts +42 -0
- package/server/api/repos/[repoId].delete.ts +27 -0
- package/server/api/repos/index.get.ts +5 -0
- package/server/api/repos/index.post.ts +39 -0
- package/server/api/watch.get.ts +63 -0
- package/server/plugins/migrate-legacy-state.ts +19 -0
- package/server/tsconfig.json +3 -0
- package/server/utils/db.ts +169 -0
- package/server/utils/git.ts +478 -0
- package/server/utils/prd-state.ts +335 -0
- package/server/utils/repos.ts +322 -0
- package/server/utils/watcher.ts +179 -0
- 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,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,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
|
+
}
|