@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,108 @@
1
+ import type { RepoConfig, AddRepoRequest } from '~/types/repo'
2
+
3
+ const STORAGE_KEY = 'prd-viewer-current-repo'
4
+
5
+ export function useRepos() {
6
+ const { showError, showSuccess } = useToast()
7
+
8
+ // Reactive repos list fetched from API
9
+ // Use key to share data, server: false to avoid SSR issues with filesystem reads
10
+ const { data: repos, refresh: refreshRepos, status, error: fetchError } = useFetch<RepoConfig[]>('/api/repos', {
11
+ key: 'repos',
12
+ default: () => [],
13
+ server: false
14
+ })
15
+
16
+ // Show error toast when fetch fails
17
+ watch(fetchError, (err) => {
18
+ if (err) {
19
+ showError('Failed to load repositories', 'Please check if the server is running.')
20
+ }
21
+ })
22
+
23
+ // Current repo ID stored in localStorage
24
+ const currentRepoId = useState<string | null>('currentRepoId', () => {
25
+ if (import.meta.client) {
26
+ return localStorage.getItem(STORAGE_KEY)
27
+ }
28
+ return null
29
+ })
30
+
31
+ // Computed current repo object
32
+ const currentRepo = computed(() => {
33
+ if (!currentRepoId.value || !repos.value) return null
34
+ return repos.value.find(r => r.id === currentRepoId.value) || null
35
+ })
36
+
37
+ // Select a repo by ID
38
+ function selectRepo(id: string | null) {
39
+ currentRepoId.value = id
40
+ if (import.meta.client) {
41
+ if (id) {
42
+ localStorage.setItem(STORAGE_KEY, id)
43
+ } else {
44
+ localStorage.removeItem(STORAGE_KEY)
45
+ }
46
+ }
47
+ }
48
+
49
+ // Add a new repo
50
+ async function addRepo(path: string, name?: string): Promise<RepoConfig> {
51
+ const body: AddRepoRequest = { path, name }
52
+ const newRepo = await $fetch<RepoConfig>('/api/repos', {
53
+ method: 'POST',
54
+ body
55
+ })
56
+ await refreshRepos()
57
+ // Auto-select the newly added repo
58
+ selectRepo(newRepo.id)
59
+ return newRepo
60
+ }
61
+
62
+ // Remove a repo by ID
63
+ async function removeRepo(id: string): Promise<void> {
64
+ try {
65
+ await $fetch(`/api/repos/${id}`, {
66
+ method: 'DELETE'
67
+ })
68
+ // If we deleted the current repo, clear selection
69
+ if (currentRepoId.value === id) {
70
+ selectRepo(null)
71
+ }
72
+ await refreshRepos()
73
+ showSuccess('Repository removed')
74
+ } catch (err) {
75
+ showError('Failed to remove repository')
76
+ throw err
77
+ }
78
+ }
79
+
80
+ // Refresh git repos discovery for a repository
81
+ async function refreshGitRepos(id: string): Promise<{ discovered: number }> {
82
+ const result = await $fetch<{ discovered: number }>(`/api/repos/${id}/refresh-git-repos`, {
83
+ method: 'POST'
84
+ })
85
+ await refreshRepos()
86
+ return result
87
+ }
88
+
89
+ // Initialize: restore from localStorage on client
90
+ if (import.meta.client) {
91
+ const storedId = localStorage.getItem(STORAGE_KEY)
92
+ if (storedId && !currentRepoId.value) {
93
+ currentRepoId.value = storedId
94
+ }
95
+ }
96
+
97
+ return {
98
+ repos,
99
+ currentRepo,
100
+ currentRepoId,
101
+ status,
102
+ selectRepo,
103
+ addRepo,
104
+ removeRepo,
105
+ refreshRepos,
106
+ refreshGitRepos
107
+ }
108
+ }
@@ -0,0 +1,38 @@
1
+ type ThemeMode = 'light' | 'dark' | 'system'
2
+
3
+ const THEME_MODE_CYCLE: ThemeMode[] = ['light', 'dark', 'system']
4
+
5
+ function normalizeThemeMode(value: unknown): ThemeMode {
6
+ if (value === 'dark' || value === 'system') {
7
+ return value
8
+ }
9
+
10
+ return 'light'
11
+ }
12
+
13
+ export function useThemeMode() {
14
+ const colorMode = useColorMode()
15
+
16
+ const themeMode = computed<ThemeMode>(() => normalizeThemeMode(colorMode.preference))
17
+
18
+ const resolvedTheme = computed<'light' | 'dark'>(() => {
19
+ return colorMode.value === 'dark' ? 'dark' : 'light'
20
+ })
21
+
22
+ function setThemeMode(mode: ThemeMode) {
23
+ colorMode.preference = mode
24
+ }
25
+
26
+ function cycleThemeMode() {
27
+ const currentIndex = THEME_MODE_CYCLE.indexOf(themeMode.value)
28
+ const nextIndex = (currentIndex + 1) % THEME_MODE_CYCLE.length
29
+ setThemeMode(THEME_MODE_CYCLE[nextIndex]!)
30
+ }
31
+
32
+ return {
33
+ themeMode,
34
+ resolvedTheme,
35
+ setThemeMode,
36
+ cycleThemeMode
37
+ }
38
+ }
@@ -0,0 +1,31 @@
1
+ import { toast } from 'vue-sonner'
2
+
3
+ export function useToast() {
4
+ function showError(message: string, description?: string) {
5
+ toast.error(message, {
6
+ description,
7
+ duration: 5000
8
+ })
9
+ }
10
+
11
+ function showSuccess(message: string, description?: string) {
12
+ toast.success(message, {
13
+ description,
14
+ duration: 3000
15
+ })
16
+ }
17
+
18
+ function showInfo(message: string, description?: string) {
19
+ toast.info(message, {
20
+ description,
21
+ duration: 3000
22
+ })
23
+ }
24
+
25
+ return {
26
+ showError,
27
+ showSuccess,
28
+ showInfo,
29
+ toast
30
+ }
31
+ }
@@ -0,0 +1,197 @@
1
+ <script setup lang="ts">
2
+ import { Monitor, Moon, Sun } from 'lucide-vue-next'
3
+ import { Button } from '~/components/ui/button'
4
+ import CommandPalette from '~/components/CommandPalette.vue'
5
+ import ShortcutsHelp from '~/components/ShortcutsHelp.vue'
6
+
7
+ const { themeMode, cycleThemeMode } = useThemeMode()
8
+ const { refreshPrds } = usePrd()
9
+ const { currentRepoId } = useRepos()
10
+ const route = useRoute()
11
+
12
+ // File change event for live updates (provided to child components)
13
+ const fileChangeEvent = ref<{ category: string; path?: string; timestamp: number } | null>(null)
14
+ provide('fileChangeEvent', fileChangeEvent)
15
+
16
+ // Command palette state
17
+ const commandPaletteOpen = ref(false)
18
+ const commandPaletteFilter = ref('')
19
+
20
+ // Shortcuts help modal state
21
+ const shortcutsHelpOpen = ref(false)
22
+
23
+ // Ref to access RepoSelector methods
24
+ const repoSelectorRef = ref<{ openAddDialog: () => void } | null>(null)
25
+
26
+ // Register keyboard shortcuts
27
+ const { onShortcut } = useKeyboard()
28
+
29
+ // Cmd/Ctrl+K to open command palette
30
+ onShortcut('Meta+k', () => {
31
+ commandPaletteOpen.value = true
32
+ })
33
+ onShortcut('Ctrl+k', () => {
34
+ commandPaletteOpen.value = true
35
+ })
36
+
37
+ // Cmd/Ctrl+J to open palette pre-filtered to PRDs
38
+ onShortcut('Meta+j', () => {
39
+ commandPaletteFilter.value = 'PRD: '
40
+ commandPaletteOpen.value = true
41
+ })
42
+ onShortcut('Ctrl+j', () => {
43
+ commandPaletteFilter.value = 'PRD: '
44
+ commandPaletteOpen.value = true
45
+ })
46
+
47
+ // Cmd/Ctrl+\ to toggle Document/Task Board tabs
48
+ function toggleTab() {
49
+ if (!import.meta.client) return
50
+ const currentTab = localStorage.getItem('prd-viewer-tab') || 'document'
51
+ const newTab = currentTab === 'document' ? 'board' : 'document'
52
+ localStorage.setItem('prd-viewer-tab', newTab)
53
+ window.dispatchEvent(new StorageEvent('storage', {
54
+ key: 'prd-viewer-tab',
55
+ newValue: newTab
56
+ }))
57
+ }
58
+ onShortcut('Meta+\\', toggleTab)
59
+ onShortcut('Ctrl+\\', toggleTab)
60
+
61
+ // Cmd/Ctrl+. to cycle theme mode (light/dark/system)
62
+ function toggleColorMode() {
63
+ cycleThemeMode()
64
+ }
65
+ onShortcut('Meta+.', toggleColorMode)
66
+ onShortcut('Ctrl+.', toggleColorMode)
67
+
68
+ // Cmd/Ctrl+, to open add repository dialog
69
+ function openAddRepoDialog() {
70
+ repoSelectorRef.value?.openAddDialog()
71
+ }
72
+ onShortcut('Meta+,', openAddRepoDialog)
73
+ onShortcut('Ctrl+,', openAddRepoDialog)
74
+
75
+ // Cmd/Ctrl+/ or Cmd/Ctrl+? to open shortcuts help
76
+ function openShortcutsHelp() {
77
+ shortcutsHelpOpen.value = true
78
+ }
79
+ onShortcut('Meta+/', openShortcutsHelp)
80
+ onShortcut('Ctrl+/', openShortcutsHelp)
81
+ onShortcut('Meta+Shift+/', openShortcutsHelp) // Cmd+? is Cmd+Shift+/
82
+ onShortcut('Ctrl+Shift+/', openShortcutsHelp) // Ctrl+? is Ctrl+Shift+/
83
+
84
+ // File watching for auto-refresh
85
+ useFileWatch((event) => {
86
+ if (event.type === 'connected') {
87
+ return
88
+ }
89
+
90
+ if (!event.category) {
91
+ return
92
+ }
93
+
94
+ const category = event.category
95
+
96
+ // Only refresh if the change is for the current repo
97
+ if (event.repoId !== currentRepoId.value) {
98
+ return
99
+ }
100
+
101
+ // Refresh PRD list for any changes
102
+ if (category === 'prd' || category === 'tasks') {
103
+ refreshPrds()
104
+ }
105
+
106
+ // For task/progress/prd changes on current PRD page, emit event for granular refresh
107
+ const prdSlug = route.params.prd as string | undefined
108
+ if (prdSlug) {
109
+ const isPrdChange = category === 'prd' && event.path?.includes(`/${prdSlug}.`)
110
+ const isTaskChange = (category === 'tasks' || category === 'progress') && event.path?.includes(`/${prdSlug}/`)
111
+
112
+ if (isPrdChange || isTaskChange) {
113
+ fileChangeEvent.value = {
114
+ category,
115
+ path: event.path,
116
+ timestamp: Date.now()
117
+ }
118
+ }
119
+ }
120
+ })
121
+ </script>
122
+
123
+ <template>
124
+ <div class="min-h-screen bg-background text-foreground">
125
+ <!-- Command Palette -->
126
+ <CommandPalette
127
+ v-model:open="commandPaletteOpen"
128
+ v-model:filter="commandPaletteFilter"
129
+ @open-shortcuts-help="openShortcutsHelp"
130
+ />
131
+
132
+ <!-- Shortcuts Help Modal -->
133
+ <ShortcutsHelp v-model:open="shortcutsHelpOpen" />
134
+ <!-- Fixed Header -->
135
+ <header
136
+ class="fixed top-0 left-0 right-0 z-50 h-14 border-b border-border bg-background/95 backdrop-blur-sm supports-[backdrop-filter]:bg-background/80"
137
+ >
138
+ <div class="flex h-full items-center justify-between px-4 md:px-6">
139
+ <!-- App Title -->
140
+ <div class="flex items-center gap-4">
141
+ <h1 class="text-lg font-semibold tracking-tight">
142
+ PRD Viewer
143
+ </h1>
144
+ </div>
145
+
146
+ <!-- Right side: Repo Selector + Theme Toggle (ClientOnly to prevent hydration mismatch) -->
147
+ <ClientOnly>
148
+ <div class="flex items-center gap-3">
149
+ <LayoutRepoSelector ref="repoSelectorRef" />
150
+ <Button
151
+ variant="ghost"
152
+ size="icon"
153
+ class="size-9"
154
+ @click="toggleColorMode"
155
+ >
156
+ <Monitor v-if="themeMode === 'system'" class="size-4" />
157
+ <Sun v-else-if="themeMode === 'light'" class="size-4" />
158
+ <Moon v-else class="size-4" />
159
+ <span class="sr-only">Cycle theme mode</span>
160
+ </Button>
161
+ </div>
162
+ <template #fallback>
163
+ <div class="flex items-center gap-3">
164
+ <div class="h-8 w-[200px] animate-pulse rounded-md bg-muted" />
165
+ <div class="size-9 animate-pulse rounded-md bg-muted" />
166
+ </div>
167
+ </template>
168
+ </ClientOnly>
169
+ </div>
170
+ </header>
171
+
172
+ <!-- Main Content Area with top padding for fixed header -->
173
+ <div class="flex h-screen pt-14">
174
+ <!-- Sidebar -->
175
+ <ClientOnly>
176
+ <LayoutSidebar />
177
+ <template #fallback>
178
+ <aside class="flex h-full w-64 flex-col border-r border-border bg-background">
179
+ <div class="flex h-12 items-center border-b border-border px-4">
180
+ <div class="h-4 w-20 animate-pulse rounded bg-muted" />
181
+ </div>
182
+ <div class="flex-1 p-2 space-y-2">
183
+ <div class="h-9 animate-pulse rounded-md bg-muted" />
184
+ <div class="h-9 animate-pulse rounded-md bg-muted" />
185
+ <div class="h-9 animate-pulse rounded-md bg-muted" />
186
+ </div>
187
+ </aside>
188
+ </template>
189
+ </ClientOnly>
190
+
191
+ <!-- Main content -->
192
+ <main class="flex-1 overflow-auto">
193
+ <slot />
194
+ </main>
195
+ </div>
196
+ </div>
197
+ </template>
@@ -0,0 +1,7 @@
1
+ import type { ClassValue } from "clsx"
2
+ import { clsx } from "clsx"
3
+ import { twMerge } from "tailwind-merge"
4
+
5
+ export function cn(...inputs: ClassValue[]) {
6
+ return twMerge(clsx(inputs))
7
+ }
@@ -0,0 +1,263 @@
1
+ <script setup lang="ts">
2
+ import { FileText, LayoutGrid, AlertCircle, Loader2, RefreshCw } from 'lucide-vue-next'
3
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs'
4
+ import { Button } from '~/components/ui/button'
5
+ import type { PrdDocument } from '~/types/prd'
6
+ import type { Task, TasksFile, ProgressFile, CommitRef } from '~/types/task'
7
+
8
+ // Disable SSR for this page - requires client-side localStorage for repo context
9
+ definePageMeta({
10
+ ssr: false
11
+ })
12
+
13
+ const route = useRoute()
14
+ const { selectRepo } = useRepos()
15
+ const { fetchDocument, fetchTasks, fetchProgress, fetchTaskCommits } = usePrd()
16
+ const { showError } = useToast()
17
+
18
+ // Get route params
19
+ const repoId = computed(() => route.params.repo as string)
20
+ const prdSlug = computed(() => route.params.prd as string)
21
+
22
+ // State
23
+ const document = ref<PrdDocument | null>(null)
24
+ const tasksFile = ref<TasksFile | null>(null)
25
+ const progressFile = ref<ProgressFile | null>(null)
26
+ const isLoading = ref(true)
27
+ const error = ref<string | null>(null)
28
+
29
+ // Active tab (persisted in localStorage)
30
+ const activeTab = ref('document')
31
+
32
+ // Load tab preference from localStorage and listen for changes
33
+ onMounted(() => {
34
+ if (import.meta.client) {
35
+ const saved = localStorage.getItem('prd-viewer-tab')
36
+ if (saved === 'document' || saved === 'board') {
37
+ activeTab.value = saved
38
+ }
39
+
40
+ // Listen for storage events from command palette
41
+ window.addEventListener('storage', (event) => {
42
+ if (event.key === 'prd-viewer-tab' && event.newValue) {
43
+ if (event.newValue === 'document' || event.newValue === 'board') {
44
+ activeTab.value = event.newValue
45
+ }
46
+ }
47
+ })
48
+ }
49
+ })
50
+
51
+ // Save tab preference
52
+ watch(activeTab, (tab) => {
53
+ if (import.meta.client) {
54
+ localStorage.setItem('prd-viewer-tab', tab)
55
+ }
56
+ })
57
+
58
+ // Selected task for detail view
59
+ const selectedTask = ref<Task | null>(null)
60
+ const detailOpen = ref(false)
61
+
62
+ // Build task title map for dependencies display
63
+ const taskTitles = computed(() => {
64
+ const map = new Map<string, string>()
65
+ if (tasksFile.value?.tasks) {
66
+ for (const task of tasksFile.value.tasks) {
67
+ map.set(task.id, task.title)
68
+ }
69
+ }
70
+ return map
71
+ })
72
+
73
+ // Inject file change event from layout for live updates
74
+ const fileChangeEvent = inject<Ref<{ category: string; path?: string; timestamp: number } | null>>('fileChangeEvent', ref(null))
75
+
76
+ // Load PRD document only
77
+ async function loadDocument() {
78
+ const doc = await fetchDocument(prdSlug.value)
79
+ if (doc) {
80
+ document.value = doc
81
+ }
82
+ }
83
+
84
+ // Load tasks and progress only
85
+ async function loadTasksAndProgress() {
86
+ const [tasks, progress] = await Promise.all([
87
+ fetchTasks(prdSlug.value),
88
+ fetchProgress(prdSlug.value)
89
+ ])
90
+ tasksFile.value = tasks
91
+ progressFile.value = progress
92
+ }
93
+
94
+ // Fetch all data (initial load and route changes)
95
+ async function loadData() {
96
+ isLoading.value = true
97
+ error.value = null
98
+
99
+ try {
100
+ // Ensure repo is selected
101
+ selectRepo(repoId.value)
102
+
103
+ // Fetch document, tasks, and progress in parallel
104
+ const [doc, tasks, progress] = await Promise.all([
105
+ fetchDocument(prdSlug.value),
106
+ fetchTasks(prdSlug.value),
107
+ fetchProgress(prdSlug.value)
108
+ ])
109
+
110
+ if (!doc) {
111
+ error.value = `PRD "${prdSlug.value}" not found in this repository.`
112
+ return
113
+ }
114
+
115
+ document.value = doc
116
+ tasksFile.value = tasks
117
+ progressFile.value = progress
118
+
119
+ // Auto-switch to document tab if no tasks exist
120
+ if (!tasks) {
121
+ activeTab.value = 'document'
122
+ }
123
+ } catch (err) {
124
+ const fetchErr = err as { statusCode?: number; data?: { message?: string } }
125
+ if (fetchErr.statusCode === 404) {
126
+ error.value = `PRD "${prdSlug.value}" not found. Check if the file exists in docs/prd/.`
127
+ } else if (fetchErr.statusCode === 500) {
128
+ error.value = 'Server error while loading the PRD. Check the file format.'
129
+ showError('Server error', fetchErr.data?.message || 'Failed to read PRD file')
130
+ } else {
131
+ error.value = 'Failed to load PRD document. Please try again.'
132
+ showError('Load failed', 'Could not fetch the PRD document')
133
+ }
134
+ } finally {
135
+ isLoading.value = false
136
+ }
137
+ }
138
+
139
+ // Watch for file changes and refresh relevant data
140
+ // Use getter function to ensure Vue properly tracks the ref value changes
141
+ watch(
142
+ () => fileChangeEvent.value,
143
+ (event) => {
144
+ if (!event) return
145
+
146
+ if (event.category === 'prd') {
147
+ loadDocument()
148
+ } else if (event.category === 'tasks' || event.category === 'progress') {
149
+ loadTasksAndProgress()
150
+ }
151
+ }
152
+ )
153
+
154
+ // Load data on mount
155
+ onMounted(loadData)
156
+
157
+ // Reload when route params change
158
+ watch([repoId, prdSlug], loadData)
159
+
160
+ // Handle task click
161
+ async function handleTaskClick(task: Task) {
162
+ selectedTask.value = task
163
+ detailOpen.value = true
164
+ // Fetch resolved commits from API (includes repo context)
165
+ selectedTaskCommits.value = await fetchTaskCommits(prdSlug.value, task.id)
166
+ }
167
+
168
+ // Resolved commits for selected task (fetched from API with repo context)
169
+ const selectedTaskCommits = ref<CommitRef[]>([])
170
+ </script>
171
+
172
+ <template>
173
+ <div class="h-full p-4 md:p-6">
174
+ <!-- Loading State -->
175
+ <div v-if="isLoading" class="flex h-full items-center justify-center">
176
+ <div class="flex flex-col items-center gap-4">
177
+ <Loader2 class="size-8 animate-spin text-primary" />
178
+ <p class="text-sm text-muted-foreground">Loading PRD...</p>
179
+ </div>
180
+ </div>
181
+
182
+ <!-- Error State -->
183
+ <div v-else-if="error" class="flex h-full items-center justify-center">
184
+ <div class="flex max-w-md flex-col items-center gap-4 text-center">
185
+ <AlertCircle class="size-12 text-destructive" />
186
+ <h2 class="text-lg font-semibold">Error Loading PRD</h2>
187
+ <p class="text-sm text-muted-foreground">{{ error }}</p>
188
+ <div class="flex gap-3">
189
+ <Button variant="outline" size="sm" @click="loadData">
190
+ <RefreshCw class="mr-2 size-4" />
191
+ Retry
192
+ </Button>
193
+ <NuxtLink to="/">
194
+ <Button variant="ghost" size="sm">
195
+ Go back home
196
+ </Button>
197
+ </NuxtLink>
198
+ </div>
199
+ </div>
200
+ </div>
201
+
202
+ <!-- Content -->
203
+ <div v-else-if="document" class="mx-auto max-w-6xl space-y-6">
204
+ <!-- Header with PRD name -->
205
+ <div class="space-y-2">
206
+ <h1 class="text-2xl font-bold tracking-tight">{{ document.name }}</h1>
207
+ <PrdMeta :metadata="document.metadata" />
208
+ </div>
209
+
210
+ <!-- Tabs -->
211
+ <Tabs v-model="activeTab" class="w-full">
212
+ <TabsList>
213
+ <TabsTrigger value="document" class="gap-2">
214
+ <FileText class="size-4" />
215
+ Document
216
+ </TabsTrigger>
217
+ <TabsTrigger value="board" class="gap-2" :disabled="!tasksFile">
218
+ <LayoutGrid class="size-4" />
219
+ Task Board
220
+ <span v-if="tasksFile" class="text-xs text-muted-foreground">
221
+ ({{ tasksFile.tasks.length }})
222
+ </span>
223
+ </TabsTrigger>
224
+ </TabsList>
225
+
226
+ <!-- Document Tab -->
227
+ <TabsContent value="document" class="mt-4">
228
+ <div class="rounded-lg border border-border bg-card p-4 md:p-6">
229
+ <PrdViewer :content="document.content" />
230
+ </div>
231
+ </TabsContent>
232
+
233
+ <!-- Board Tab -->
234
+ <TabsContent value="board" class="mt-4">
235
+ <div v-if="tasksFile" class="h-[calc(100vh-280px)] min-h-[400px]">
236
+ <TasksBoard
237
+ :tasks="tasksFile.tasks"
238
+ @task-click="handleTaskClick"
239
+ />
240
+ </div>
241
+ <div v-else class="py-12 text-center">
242
+ <LayoutGrid class="mx-auto size-12 text-muted-foreground/50" />
243
+ <p class="mt-4 text-muted-foreground">
244
+ No tasks found for this PRD
245
+ </p>
246
+ <p class="mt-1 text-sm text-muted-foreground/70">
247
+ Task state has not been generated for this PRD yet.
248
+ </p>
249
+ </div>
250
+ </TabsContent>
251
+ </Tabs>
252
+ </div>
253
+
254
+ <!-- Task Detail Sheet -->
255
+ <TasksDetail
256
+ v-model:open="detailOpen"
257
+ :task="selectedTask"
258
+ :task-titles="taskTitles"
259
+ :commits="selectedTaskCommits"
260
+ :repo-id="repoId"
261
+ />
262
+ </div>
263
+ </template>