@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,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>
|
package/app/lib/utils.ts
ADDED
|
@@ -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>
|