sillyspec 3.7.7 → 3.7.9
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/.sillyspec/changes/dashboard/design.md +219 -0
- package/.sillyspec/plans/2026-04-05-dashboard.md +737 -0
- package/.sillyspec/specs/2026-04-05-dashboard-design.md +206 -0
- package/bin/sillyspec.js +0 -0
- package/package.json +1 -1
- package/packages/dashboard/dist/assets/index-Bh-GPjKY.css +1 -0
- package/packages/dashboard/dist/assets/index-CrCn5Gg6.js +17 -0
- package/packages/dashboard/dist/index.html +16 -0
- package/packages/dashboard/index.html +15 -0
- package/packages/dashboard/package-lock.json +2164 -0
- package/packages/dashboard/package.json +22 -0
- package/packages/dashboard/server/executor.js +86 -0
- package/packages/dashboard/server/index.js +359 -0
- package/packages/dashboard/server/parser.js +154 -0
- package/packages/dashboard/server/watcher.js +277 -0
- package/packages/dashboard/src/App.vue +154 -0
- package/packages/dashboard/src/components/ActionBar.vue +100 -0
- package/packages/dashboard/src/components/CommandPalette.vue +117 -0
- package/packages/dashboard/src/components/DetailPanel.vue +122 -0
- package/packages/dashboard/src/components/LogStream.vue +85 -0
- package/packages/dashboard/src/components/PipelineStage.vue +75 -0
- package/packages/dashboard/src/components/PipelineView.vue +94 -0
- package/packages/dashboard/src/components/ProjectList.vue +152 -0
- package/packages/dashboard/src/components/StageBadge.vue +53 -0
- package/packages/dashboard/src/components/StepCard.vue +89 -0
- package/packages/dashboard/src/composables/useDashboard.js +171 -0
- package/packages/dashboard/src/composables/useKeyboard.js +117 -0
- package/packages/dashboard/src/composables/useWebSocket.js +129 -0
- package/packages/dashboard/src/main.js +5 -0
- package/packages/dashboard/src/style.css +132 -0
- package/packages/dashboard/vite.config.js +18 -0
- package/src/index.js +68 -8
- package/src/init.js +23 -1
- package/src/progress.js +422 -0
- package/src/setup.js +16 -0
- package/templates/archive.md +56 -0
- package/templates/brainstorm.md +82 -26
- package/templates/commit.md +2 -0
- package/templates/execute.md +20 -1
- package/templates/progress-format.md +90 -0
- package/templates/quick.md +36 -3
- package/templates/resume-dialog.md +55 -0
- package/templates/skills/playwright-e2e/SKILL.md +1 -1
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import chokidar from 'chokidar'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { homedir } from 'os'
|
|
4
|
+
import { existsSync } from 'fs'
|
|
5
|
+
import { parseProjectState } from './parser.js'
|
|
6
|
+
|
|
7
|
+
let watcher = null
|
|
8
|
+
let updateCallback = null
|
|
9
|
+
let projectStates = new Map()
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Start watching all .sillyspec directories
|
|
13
|
+
* @param {function} callback - Callback function when projects are updated
|
|
14
|
+
* @returns {object} The watcher instance
|
|
15
|
+
*/
|
|
16
|
+
export function startWatcher(callback) {
|
|
17
|
+
if (watcher) {
|
|
18
|
+
stopWatcher()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
updateCallback = callback
|
|
22
|
+
|
|
23
|
+
// Discover all .sillyspec directories
|
|
24
|
+
const home = homedir()
|
|
25
|
+
const cwd = process.cwd()
|
|
26
|
+
|
|
27
|
+
// Directories to exclude (system junk, cache, etc.)
|
|
28
|
+
const excludeDirs = new Set([
|
|
29
|
+
'.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
|
|
30
|
+
'.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
|
|
31
|
+
'.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew'
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
// Helper to check if directory should be excluded
|
|
35
|
+
const shouldExclude = (name) => {
|
|
36
|
+
// Check exact matches
|
|
37
|
+
if (excludeDirs.has(name)) return true
|
|
38
|
+
// Check wildcard patterns (like .Trash-*)
|
|
39
|
+
for (const pattern of excludeDirs) {
|
|
40
|
+
if (pattern.includes('*')) {
|
|
41
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
|
|
42
|
+
if (regex.test(name)) return true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Exclude hidden directories (starting with .) unless it's the cwd basename
|
|
46
|
+
if (name.startsWith('.') && name !== cwd.split('/').pop()) {
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Build scan directories: cwd + home subdirs + common project locations
|
|
53
|
+
const scanDirs = [cwd, home]
|
|
54
|
+
const extraDirs = ['Desktop', 'Documents', 'Projects', 'Work', 'Repos', 'Code', 'src', 'dev']
|
|
55
|
+
|
|
56
|
+
for (const extra of extraDirs) {
|
|
57
|
+
const extraPath = join(home, extra)
|
|
58
|
+
if (existsSync(extraPath)) {
|
|
59
|
+
scanDirs.push(extraPath)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const watchPaths = []
|
|
64
|
+
const projects = []
|
|
65
|
+
const seen = new Set() // Dedupe by path
|
|
66
|
+
|
|
67
|
+
for (const baseDir of scanDirs) {
|
|
68
|
+
try {
|
|
69
|
+
const { readdirSync } = require('fs')
|
|
70
|
+
const entries = readdirSync(baseDir, { withFileTypes: true })
|
|
71
|
+
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (!entry.isDirectory()) continue
|
|
74
|
+
if (shouldExclude(entry.name)) continue
|
|
75
|
+
|
|
76
|
+
const dirPath = join(baseDir, entry.name)
|
|
77
|
+
|
|
78
|
+
// Skip if we've already seen this path
|
|
79
|
+
if (seen.has(dirPath)) continue
|
|
80
|
+
seen.add(dirPath)
|
|
81
|
+
|
|
82
|
+
const sillyspecPath = join(dirPath, '.sillyspec')
|
|
83
|
+
|
|
84
|
+
if (existsSync(sillyspecPath)) {
|
|
85
|
+
watchPaths.push(sillyspecPath)
|
|
86
|
+
projects.push({
|
|
87
|
+
name: entry.name,
|
|
88
|
+
path: dirPath
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
// Skip directories we can't read
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Parse initial states
|
|
99
|
+
for (const project of projects) {
|
|
100
|
+
const state = parseProjectState(project.path)
|
|
101
|
+
if (state) {
|
|
102
|
+
projectStates.set(project.name, { ...project, state })
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Emit initial state
|
|
107
|
+
if (updateCallback) {
|
|
108
|
+
updateCallback(Array.from(projectStates.values()))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Watch for changes
|
|
112
|
+
watcher = chokidar.watch(watchPaths, {
|
|
113
|
+
ignored: /node_modules/,
|
|
114
|
+
persistent: true,
|
|
115
|
+
ignoreInitial: true,
|
|
116
|
+
awaitWriteFinish: {
|
|
117
|
+
stabilityThreshold: 300,
|
|
118
|
+
pollInterval: 100
|
|
119
|
+
},
|
|
120
|
+
depth: 5
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
watcher.on('change', async (filePath) => {
|
|
124
|
+
await handleFileChange(filePath)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
watcher.on('add', async (filePath) => {
|
|
128
|
+
await handleFileChange(filePath)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
watcher.on('unlink', async (filePath) => {
|
|
132
|
+
await handleFileChange(filePath)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
watcher.on('error', (error) => {
|
|
136
|
+
console.error('Watcher error:', error)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
return watcher
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Handle file change events
|
|
144
|
+
* @param {string} filePath - Path to the changed file
|
|
145
|
+
*/
|
|
146
|
+
async function handleFileChange(filePath) {
|
|
147
|
+
// Find which project this file belongs to
|
|
148
|
+
const projectName = Array.from(projectStates.values()).find(p =>
|
|
149
|
+
filePath.startsWith(p.path)
|
|
150
|
+
)?.name
|
|
151
|
+
|
|
152
|
+
if (!projectName) {
|
|
153
|
+
// Re-scan for new projects
|
|
154
|
+
await rescanProjects()
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Re-parse the project state
|
|
159
|
+
const project = projectStates.get(projectName)
|
|
160
|
+
if (project) {
|
|
161
|
+
const newState = parseProjectState(project.path)
|
|
162
|
+
if (newState) {
|
|
163
|
+
projectStates.set(projectName, { ...project, state: newState })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Emit updated state
|
|
168
|
+
if (updateCallback) {
|
|
169
|
+
updateCallback(Array.from(projectStates.values()))
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Re-scan for projects (e.g., new .sillyspec directories)
|
|
175
|
+
*/
|
|
176
|
+
async function rescanProjects() {
|
|
177
|
+
const home = homedir()
|
|
178
|
+
const cwd = process.cwd()
|
|
179
|
+
|
|
180
|
+
// Directories to exclude (system junk, cache, etc.)
|
|
181
|
+
const excludeDirs = new Set([
|
|
182
|
+
'.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
|
|
183
|
+
'.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
|
|
184
|
+
'.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew'
|
|
185
|
+
])
|
|
186
|
+
|
|
187
|
+
const shouldExclude = (name) => {
|
|
188
|
+
if (excludeDirs.has(name)) return true
|
|
189
|
+
for (const pattern of excludeDirs) {
|
|
190
|
+
if (pattern.includes('*')) {
|
|
191
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
|
|
192
|
+
if (regex.test(name)) return true
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (name.startsWith('.') && name !== cwd.split('/').pop()) {
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Build scan directories
|
|
202
|
+
const scanDirs = [cwd, home]
|
|
203
|
+
const extraDirs = ['Desktop', 'Documents', 'Projects', 'Work', 'Repos', 'Code', 'src', 'dev']
|
|
204
|
+
|
|
205
|
+
for (const extra of extraDirs) {
|
|
206
|
+
const extraPath = join(home, extra)
|
|
207
|
+
if (existsSync(extraPath)) {
|
|
208
|
+
scanDirs.push(extraPath)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const seen = new Set()
|
|
213
|
+
|
|
214
|
+
for (const baseDir of scanDirs) {
|
|
215
|
+
try {
|
|
216
|
+
const { readdirSync } = require('fs')
|
|
217
|
+
const entries = readdirSync(baseDir, { withFileTypes: true })
|
|
218
|
+
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
if (!entry.isDirectory()) continue
|
|
221
|
+
if (shouldExclude(entry.name)) continue
|
|
222
|
+
|
|
223
|
+
const dirPath = join(baseDir, entry.name)
|
|
224
|
+
if (seen.has(dirPath)) continue
|
|
225
|
+
seen.add(dirPath)
|
|
226
|
+
|
|
227
|
+
const sillyspecPath = join(dirPath, '.sillyspec')
|
|
228
|
+
|
|
229
|
+
if (existsSync(sillyspecPath) && !projectStates.has(entry.name)) {
|
|
230
|
+
const state = parseProjectState(dirPath)
|
|
231
|
+
if (state) {
|
|
232
|
+
projectStates.set(entry.name, {
|
|
233
|
+
name: entry.name,
|
|
234
|
+
path: dirPath,
|
|
235
|
+
state
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch (err) {
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (updateCallback) {
|
|
246
|
+
updateCallback(Array.from(projectStates.values()))
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Stop the file watcher
|
|
252
|
+
*/
|
|
253
|
+
export function stopWatcher() {
|
|
254
|
+
if (watcher) {
|
|
255
|
+
watcher.close()
|
|
256
|
+
watcher = null
|
|
257
|
+
projectStates.clear()
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get current project states
|
|
263
|
+
* @returns {array} Array of project states
|
|
264
|
+
*/
|
|
265
|
+
export function getProjectStates() {
|
|
266
|
+
return Array.from(projectStates.values())
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get a specific project state
|
|
271
|
+
* @param {string} projectName - Name of the project
|
|
272
|
+
* @returns {object|null} Project state or null
|
|
273
|
+
*/
|
|
274
|
+
export function getProjectState(projectName) {
|
|
275
|
+
return projectStates.get(projectName) || null
|
|
276
|
+
}
|
|
277
|
+
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="h-screen w-screen flex flex-col overflow-hidden font-[DM_Sans,sans-serif] relative" style="background-color: #0A0A0B;">
|
|
3
|
+
<!-- Ambient background -->
|
|
4
|
+
<div class="absolute inset-0 pointer-events-none" style="background: radial-gradient(ellipse 60% 40% at 10% 20%, rgba(251,191,36,0.04) 0%, transparent 70%), radial-gradient(ellipse 50% 50% at 90% 80%, rgba(251,191,36,0.02) 0%, transparent 70%);" />
|
|
5
|
+
|
|
6
|
+
<!-- Main Content -->
|
|
7
|
+
<div class="flex-1 flex overflow-hidden relative z-10">
|
|
8
|
+
<!-- Left: Project List -->
|
|
9
|
+
<aside class="w-[240px] flex-shrink-0 relative" style="background: #111113; border-right: 1px solid #1F1F22;">
|
|
10
|
+
<ProjectList
|
|
11
|
+
:projects="dashboard.state.projects"
|
|
12
|
+
:active-project="dashboard.state.activeProject"
|
|
13
|
+
:is-loading="dashboard.state.isLoading"
|
|
14
|
+
@select="handleSelectProject"
|
|
15
|
+
/>
|
|
16
|
+
</aside>
|
|
17
|
+
|
|
18
|
+
<!-- Center: Pipeline View -->
|
|
19
|
+
<main class="flex-1 overflow-hidden accent-stripe">
|
|
20
|
+
<PipelineView
|
|
21
|
+
:project="dashboard.state.activeProject"
|
|
22
|
+
:active-step="dashboard.state.activeStep"
|
|
23
|
+
@select-step="handleSelectStep"
|
|
24
|
+
/>
|
|
25
|
+
</main>
|
|
26
|
+
|
|
27
|
+
<!-- Right: Detail Panel -->
|
|
28
|
+
<aside
|
|
29
|
+
:class="[
|
|
30
|
+
'flex-shrink-0 transition-all duration-300 relative overflow-hidden',
|
|
31
|
+
dashboard.state.isPanelOpen ? 'w-[340px]' : 'w-0'
|
|
32
|
+
]"
|
|
33
|
+
style="background: #111113; border-left: 1px solid #1F1F22;"
|
|
34
|
+
>
|
|
35
|
+
<DetailPanel
|
|
36
|
+
:is-open="dashboard.state.isPanelOpen"
|
|
37
|
+
:active-step="dashboard.state.activeStep"
|
|
38
|
+
:logs="dashboard.state.logs"
|
|
39
|
+
@close="dashboard.closePanel"
|
|
40
|
+
@clear-logs="dashboard.clearLogs"
|
|
41
|
+
/>
|
|
42
|
+
</aside>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<!-- Bottom: Action Bar -->
|
|
46
|
+
<ActionBar
|
|
47
|
+
:project="dashboard.state.activeProject"
|
|
48
|
+
:is-executing="dashboard.state.executingProject !== null"
|
|
49
|
+
:execution-result="executionResult"
|
|
50
|
+
:is-panel-open="dashboard.state.isPanelOpen"
|
|
51
|
+
@execute="handleExecute"
|
|
52
|
+
@kill="handleKill"
|
|
53
|
+
@toggle-panel="dashboard.togglePanel"
|
|
54
|
+
@open-palette="isCommandPaletteOpen = true"
|
|
55
|
+
/>
|
|
56
|
+
|
|
57
|
+
<!-- Command Palette Overlay -->
|
|
58
|
+
<CommandPalette
|
|
59
|
+
:is-open="isCommandPaletteOpen"
|
|
60
|
+
:projects="dashboard.state.projects"
|
|
61
|
+
@close="isCommandPaletteOpen = false"
|
|
62
|
+
@select-project="handleSelectProject"
|
|
63
|
+
@select-stage="handleSelectStage"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
</template>
|
|
67
|
+
|
|
68
|
+
<script setup>
|
|
69
|
+
import { ref, onMounted } from 'vue'
|
|
70
|
+
import { useWebSocket } from './composables/useWebSocket.js'
|
|
71
|
+
import { useDashboard } from './composables/useDashboard.js'
|
|
72
|
+
import { useDashboardKeyboard } from './composables/useKeyboard.js'
|
|
73
|
+
import ProjectList from './components/ProjectList.vue'
|
|
74
|
+
import PipelineView from './components/PipelineView.vue'
|
|
75
|
+
import DetailPanel from './components/DetailPanel.vue'
|
|
76
|
+
import ActionBar from './components/ActionBar.vue'
|
|
77
|
+
import CommandPalette from './components/CommandPalette.vue'
|
|
78
|
+
|
|
79
|
+
// Composables
|
|
80
|
+
const ws = useWebSocket()
|
|
81
|
+
const dashboard = useDashboard()
|
|
82
|
+
const isCommandPaletteOpen = ref(false)
|
|
83
|
+
const executionResult = ref(null)
|
|
84
|
+
|
|
85
|
+
// Keyboard shortcuts
|
|
86
|
+
useDashboardKeyboard({
|
|
87
|
+
onOpenCommandPalette: () => { isCommandPaletteOpen.value = true },
|
|
88
|
+
onClose: () => {
|
|
89
|
+
if (isCommandPaletteOpen.value) {
|
|
90
|
+
isCommandPaletteOpen.value = false
|
|
91
|
+
} else if (dashboard.state.activeStep) {
|
|
92
|
+
dashboard.state.activeStep = null
|
|
93
|
+
} else {
|
|
94
|
+
dashboard.closePanel()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// WebSocket event handlers
|
|
100
|
+
onMounted(() => {
|
|
101
|
+
ws.on('projects:init', (projects) => { dashboard.updateProjects(projects) })
|
|
102
|
+
ws.on('projects:updated', (projects) => { dashboard.updateProjects(projects) })
|
|
103
|
+
ws.on('cli:output', (data) => {
|
|
104
|
+
if (data.projectName === dashboard.activeProjectName.value) {
|
|
105
|
+
dashboard.appendLog(data.output)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
ws.on('cli:complete', (data) => {
|
|
109
|
+
if (data.projectName === dashboard.activeProjectName.value) {
|
|
110
|
+
dashboard.setExecuting(null)
|
|
111
|
+
executionResult.value = { exitCode: data.exitCode, signal: data.signal }
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
ws.on('cli:started', (data) => {
|
|
115
|
+
if (data.projectName === dashboard.activeProjectName.value) {
|
|
116
|
+
dashboard.setExecuting(data.projectName)
|
|
117
|
+
executionResult.value = null
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
ws.on('cli:killed', (data) => {
|
|
121
|
+
if (data.projectName === dashboard.activeProjectName.value) {
|
|
122
|
+
dashboard.setExecuting(null)
|
|
123
|
+
executionResult.value = { exitCode: -1, signal: 'SIGTERM' }
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
function handleSelectProject(project) { dashboard.selectProject(project) }
|
|
129
|
+
function handleSelectStage({ project, stage }) { dashboard.selectProject(project) }
|
|
130
|
+
function handleSelectStep(step) { dashboard.selectStep(step) }
|
|
131
|
+
function handleExecute() {
|
|
132
|
+
const projectName = dashboard.activeProjectName.value
|
|
133
|
+
if (!projectName) return
|
|
134
|
+
dashboard.clearLogs()
|
|
135
|
+
ws.send({ type: 'cli:execute', data: { projectName, command: 'next' } })
|
|
136
|
+
}
|
|
137
|
+
function handleKill() {
|
|
138
|
+
const projectName = dashboard.activeProjectName.value
|
|
139
|
+
if (!projectName) return
|
|
140
|
+
ws.send({ type: 'cli:kill', data: { projectName } })
|
|
141
|
+
}
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<style>
|
|
145
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
146
|
+
|
|
147
|
+
body {
|
|
148
|
+
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
149
|
+
-webkit-font-smoothing: antialiased;
|
|
150
|
+
-moz-osx-font-smoothing: grayscale;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#app { width: 100vw; height: 100vh; overflow: hidden; }
|
|
154
|
+
</style>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="h-12 flex items-center justify-between px-5 relative" style="background: rgba(17,17,19,0.9); backdrop-filter: blur(20px); border-top: 1px solid #1F1F22;">
|
|
3
|
+
<!-- Ambient top glow -->
|
|
4
|
+
<div class="absolute inset-x-0 top-0 h-px" style="background: linear-gradient(90deg, transparent, rgba(251,191,36,0.1), transparent);"></div>
|
|
5
|
+
|
|
6
|
+
<!-- Left: Status -->
|
|
7
|
+
<div class="flex items-center gap-3">
|
|
8
|
+
<div v-if="project" class="flex items-center gap-2">
|
|
9
|
+
<span class="text-[11px] font-[JetBrains_Mono,monospace]" style="color: #525252;">{{ project.name }}</span>
|
|
10
|
+
<span style="color: #1F1F22;">|</span>
|
|
11
|
+
<StageBadge v-if="project.state?.currentStage" :status="getProjectStatus()" :label="stageLabel()" />
|
|
12
|
+
</div>
|
|
13
|
+
<div v-else class="text-[10px] font-[JetBrains_Mono,monospace]" style="color: #3A3A3D;">
|
|
14
|
+
no project
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
|
|
18
|
+
<!-- Center: Execution -->
|
|
19
|
+
<div class="flex items-center gap-2">
|
|
20
|
+
<div v-if="isExecuting" class="flex items-center gap-2">
|
|
21
|
+
<div class="w-1 h-1 rounded-full animate-pulse-dot" style="background: #FBBF24;" />
|
|
22
|
+
<span class="text-[10px] font-[JetBrains_Mono,monospace]" style="color: #FBBF24;">running</span>
|
|
23
|
+
</div>
|
|
24
|
+
<div v-else-if="executionResult" class="flex items-center gap-1.5">
|
|
25
|
+
<span class="text-[10px] font-[JetBrains_Mono,monospace]" :style="{ color: executionResult.exitCode === 0 ? '#34D399' : '#EF4444' }">
|
|
26
|
+
{{ executionResult.exitCode === 0 ? '● done' : `● exit ${executionResult.exitCode}` }}
|
|
27
|
+
</span>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<!-- Right: Actions -->
|
|
32
|
+
<div class="flex items-center gap-1">
|
|
33
|
+
<button
|
|
34
|
+
@click="$emit('toggle-panel')"
|
|
35
|
+
class="p-1.5 rounded-sm transition-colors duration-100"
|
|
36
|
+
style="color: #525252;"
|
|
37
|
+
@mouseenter="$event.target.style.color='#FBBF24';$event.target.style.background='rgba(251,191,36,0.06)'"
|
|
38
|
+
@mouseleave="$event.target.style.color='#525252';$event.target.style.background='transparent'"
|
|
39
|
+
title="Toggle detail panel"
|
|
40
|
+
>
|
|
41
|
+
<svg :class="['w-3.5 h-3.5 transition-transform duration-200', { 'rotate-180': !isPanelOpen }]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
42
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
43
|
+
</svg>
|
|
44
|
+
</button>
|
|
45
|
+
|
|
46
|
+
<button
|
|
47
|
+
v-if="isExecuting"
|
|
48
|
+
@click="$emit('kill')"
|
|
49
|
+
class="px-2.5 py-1 rounded-sm text-[10px] font-[JetBrains_Mono,monospace] transition-colors duration-100"
|
|
50
|
+
style="background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.2); color: #EF4444;"
|
|
51
|
+
>
|
|
52
|
+
kill
|
|
53
|
+
</button>
|
|
54
|
+
|
|
55
|
+
<button
|
|
56
|
+
@click="$emit('open-palette')"
|
|
57
|
+
class="p-1.5 rounded-sm transition-colors duration-100"
|
|
58
|
+
style="color: #525252;"
|
|
59
|
+
@mouseenter="$event.target.style.color='#FBBF24';$event.target.style.background='rgba(251,191,36,0.06)'"
|
|
60
|
+
@mouseleave="$event.target.style.color='#525252';$event.target.style.background='transparent'"
|
|
61
|
+
title="Command palette (⌘K)"
|
|
62
|
+
>
|
|
63
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
64
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
65
|
+
</svg>
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<script setup>
|
|
72
|
+
import { computed } from 'vue'
|
|
73
|
+
import StageBadge from './StageBadge.vue'
|
|
74
|
+
|
|
75
|
+
const props = defineProps({
|
|
76
|
+
project: { type: Object, default: null },
|
|
77
|
+
isExecuting: { type: Boolean, default: false },
|
|
78
|
+
executionResult: { type: Object, default: null },
|
|
79
|
+
isPanelOpen: { type: Boolean, default: true }
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const emit = defineEmits(['execute', 'kill', 'toggle-panel', 'open-palette'])
|
|
83
|
+
|
|
84
|
+
function getProjectStatus() {
|
|
85
|
+
if (!props.project?.state) return 'pending'
|
|
86
|
+
const stage = props.project.state.currentStage
|
|
87
|
+
const steps = props.project.state.progress?.stages?.[stage]?.steps || []
|
|
88
|
+
if (steps.some(s => s.status === 'failed')) return 'failed'
|
|
89
|
+
if (steps.some(s => s.status === 'blocked')) return 'blocked'
|
|
90
|
+
if (steps.some(s => s.status === 'in-progress')) return 'in-progress'
|
|
91
|
+
if (steps.every(s => s.status === 'completed')) return 'completed'
|
|
92
|
+
return 'pending'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stageLabel() {
|
|
96
|
+
const stage = props.project?.state?.currentStage
|
|
97
|
+
const labels = { 'brainstorm': '头脑风暴', 'plan': '规划', 'execute': '执行', 'verify': '验证' }
|
|
98
|
+
return labels[stage] || stage || '未知'
|
|
99
|
+
}
|
|
100
|
+
</script>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Teleport to="body">
|
|
3
|
+
<Transition name="backdrop">
|
|
4
|
+
<div v-if="isOpen" class="fixed inset-0 z-40" style="background: rgba(0,0,0,0.7); backdrop-filter: blur(4px);" @click="close" />
|
|
5
|
+
</Transition>
|
|
6
|
+
|
|
7
|
+
<Transition name="palette">
|
|
8
|
+
<div v-if="isOpen" class="fixed left-1/2 top-[18%] -translate-x-1/2 w-full max-w-md z-50">
|
|
9
|
+
<div class="overflow-hidden rounded-md" style="background: #141416; border: 1px solid #2A2A2D; box-shadow: 0 25px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(251,191,36,0.05);">
|
|
10
|
+
<!-- Search -->
|
|
11
|
+
<div class="px-4 py-3" style="border-bottom: 1px solid #1F1F22;">
|
|
12
|
+
<div class="flex items-center gap-3">
|
|
13
|
+
<svg class="w-3.5 h-3.5 flex-shrink-0" style="color: #525252;" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
14
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
15
|
+
</svg>
|
|
16
|
+
<input
|
|
17
|
+
ref="searchInput"
|
|
18
|
+
v-model="searchQuery"
|
|
19
|
+
type="text"
|
|
20
|
+
placeholder="Search projects or stages..."
|
|
21
|
+
class="flex-1 bg-transparent border-none outline-none text-[12px] font-[JetBrains_Mono,monospace]"
|
|
22
|
+
style="color: #E4E4E7;"
|
|
23
|
+
@keydown="handleKeydown"
|
|
24
|
+
/>
|
|
25
|
+
<kbd v-if="searchQuery" class="text-[9px] px-1 py-0.5 rounded-sm font-mono-log" style="color: #525252; background: #0A0A0B; border: 1px solid #2A2A2D;">ESC</kbd>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<!-- Results -->
|
|
30
|
+
<div class="max-h-72 overflow-y-auto">
|
|
31
|
+
<div v-if="filteredItems.length === 0" class="py-10 text-center">
|
|
32
|
+
<p class="text-[11px] font-[JetBrains_Mono,monospace]" style="color: #3A3A3D;">No results</p>
|
|
33
|
+
</div>
|
|
34
|
+
<div v-else class="py-0.5">
|
|
35
|
+
<div
|
|
36
|
+
v-for="(item, index) in filteredItems"
|
|
37
|
+
:key="item.id"
|
|
38
|
+
:class="['px-4 py-2.5 cursor-pointer transition-colors duration-100 flex items-center gap-3']"
|
|
39
|
+
:style="{ background: selectedIndex === index ? 'rgba(251,191,36,0.06)' : 'transparent' }"
|
|
40
|
+
@click="selectItem(item)"
|
|
41
|
+
@mouseenter="selectedIndex = index"
|
|
42
|
+
>
|
|
43
|
+
<div class="w-6 h-6 rounded-sm flex items-center justify-center text-[10px] font-[JetBrains_Mono,monospace] flex-shrink-0" style="background: #0A0A0B; border: 1px solid #1F1F22; color: #525252;">
|
|
44
|
+
{{ item.type === 'project' ? '□' : '◇' }}
|
|
45
|
+
</div>
|
|
46
|
+
<div class="flex-1 min-w-0">
|
|
47
|
+
<div class="text-[12px] font-medium truncate font-[JetBrains_Mono,monospace]" style="color: #E4E4E7;">{{ item.title }}</div>
|
|
48
|
+
<div class="text-[10px] truncate" style="color: #525252;">{{ item.subtitle }}</div>
|
|
49
|
+
</div>
|
|
50
|
+
<StageBadge v-if="item.status" :status="item.status" size="sm" />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<!-- Footer -->
|
|
56
|
+
<div class="px-4 py-2 flex items-center gap-4 text-[9px] font-mono-log" style="border-top: 1px solid #1F1F22; color: #3A3A3D;">
|
|
57
|
+
<span><kbd class="px-1 rounded-sm" style="background: #0A0A0B; border: 1px solid #1F1F22;">↑↓</kbd> nav</span>
|
|
58
|
+
<span><kbd class="px-1 rounded-sm" style="background: #0A0A0B; border: 1px solid #1F1F22;">↵</kbd> open</span>
|
|
59
|
+
<span><kbd class="px-1 rounded-sm" style="background: #0A0A0B; border: 1px solid #1F1F22;">esc</kbd> close</span>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</Transition>
|
|
64
|
+
</Teleport>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<script setup>
|
|
68
|
+
import { ref, computed, watch, nextTick } from 'vue'
|
|
69
|
+
import StageBadge from './StageBadge.vue'
|
|
70
|
+
|
|
71
|
+
const props = defineProps({ isOpen: { type: Boolean, default: false }, projects: { type: Array, default: () => [] } })
|
|
72
|
+
const emit = defineEmits(['close', 'select-project', 'select-stage'])
|
|
73
|
+
|
|
74
|
+
const searchQuery = ref('')
|
|
75
|
+
const searchInput = ref(null)
|
|
76
|
+
const selectedIndex = ref(0)
|
|
77
|
+
|
|
78
|
+
const stageNames = [
|
|
79
|
+
{ id: 'brainstorm', name: '头脑风暴' },
|
|
80
|
+
{ id: 'plan', name: '规划' },
|
|
81
|
+
{ id: 'execute', name: '执行' },
|
|
82
|
+
{ id: 'verify', name: '验证' }
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const filteredItems = computed(() => {
|
|
86
|
+
const items = []
|
|
87
|
+
for (const project of props.projects) {
|
|
88
|
+
const pm = !searchQuery.value || project.name.toLowerCase().includes(searchQuery.value.toLowerCase()) || project.path.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
89
|
+
if (pm) items.push({ id: `project-${project.name}`, type: 'project', title: project.name, subtitle: project.path, data: project, status: getProjectStatus(project) })
|
|
90
|
+
for (const stage of stageNames) {
|
|
91
|
+
const sm = !searchQuery.value || stage.name.toLowerCase().includes(searchQuery.value.toLowerCase()) || stage.id.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
92
|
+
if (sm || pm) items.push({ id: `stage-${project.name}-${stage.id}`, type: 'stage', title: `${project.name} / ${stage.name}`, subtitle: `Jump to ${stage.name}`, data: { project, stage: stage.id }, status: getStageStatus(project, stage.id) })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return items
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
function getProjectStatus(p) { const s = p.state?.progress?.stages?.[p.state?.currentStage]?.steps || []; if (s.some(x => x.status === 'failed')) return 'failed'; if (s.some(x => x.status === 'in-progress')) return 'in-progress'; if (s.every(x => x.status === 'completed')) return 'completed'; return 'pending' }
|
|
99
|
+
function getStageStatus(p, id) { const s = p.state?.progress?.stages?.[id]?.steps || []; if (!s.length) return 'pending'; if (s.some(x => x.status === 'failed')) return 'failed'; if (s.some(x => x.status === 'in-progress')) return 'in-progress'; if (s.every(x => x.status === 'completed')) return 'completed'; return 'pending' }
|
|
100
|
+
function close() { searchQuery.value = ''; selectedIndex.value = 0; emit('close') }
|
|
101
|
+
function selectItem(item) { if (item.type === 'project') emit('select-project', item.data); else emit('select-stage', item.data); close() }
|
|
102
|
+
function handleKeydown(e) {
|
|
103
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); selectedIndex.value = Math.min(selectedIndex.value + 1, filteredItems.value.length - 1) }
|
|
104
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIndex.value = Math.max(selectedIndex.value - 1, 0) }
|
|
105
|
+
else if (e.key === 'Enter') { e.preventDefault(); if (filteredItems.value[selectedIndex.value]) selectItem(filteredItems.value[selectedIndex.value]) }
|
|
106
|
+
else if (e.key === 'Escape') { e.preventDefault(); close() }
|
|
107
|
+
}
|
|
108
|
+
watch(filteredItems, () => { selectedIndex.value = 0 })
|
|
109
|
+
watch(() => props.isOpen, (v) => { if (v) nextTick(() => { searchInput.value?.focus() }) })
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<style scoped>
|
|
113
|
+
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 150ms ease; }
|
|
114
|
+
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
|
115
|
+
.palette-enter-active, .palette-leave-active { transition: all 200ms cubic-bezier(0.16, 1, 0.3, 1); }
|
|
116
|
+
.palette-enter-from, .palette-leave-to { opacity: 0; transform: translate(-50%, -8px) scale(0.98); }
|
|
117
|
+
</style>
|