mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
restyle
This commit is contained in:
parent
88e27c04b7
commit
d0406564db
8 changed files with 1172 additions and 572 deletions
|
|
@ -46,6 +46,12 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
|||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||
import { triggerTrackUpdate } from '@x/core/dist/knowledge/track/runner.js';
|
||||
import { trackBus } from '@x/core/dist/knowledge/track/bus.js';
|
||||
import {
|
||||
fetchYaml,
|
||||
updateTrackBlock,
|
||||
replaceTrackBlockYaml,
|
||||
deleteTrackBlock,
|
||||
} from '@x/core/dist/knowledge/track/fileops.js';
|
||||
|
||||
/**
|
||||
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
||||
|
|
@ -773,11 +779,48 @@ export function setupIpcHandlers() {
|
|||
'voice:synthesize': async (_event, args) => {
|
||||
return voice.synthesizeSpeech(args.text);
|
||||
},
|
||||
// Track handler
|
||||
// Track handlers
|
||||
'track:run': async (_event, args) => {
|
||||
const result = await triggerTrackUpdate(args.trackId, args.filePath);
|
||||
return { success: !result.error, summary: result.summary ?? undefined, error: result.error };
|
||||
},
|
||||
'track:get': async (_event, args) => {
|
||||
try {
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
if (yaml === null) return { success: false, error: 'Track not found' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:update': async (_event, args) => {
|
||||
try {
|
||||
await updateTrackBlock(args.filePath, args.trackId, args.updates as Record<string, unknown>);
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
if (yaml === null) return { success: false, error: 'Track vanished after update' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:replaceYaml': async (_event, args) => {
|
||||
try {
|
||||
await replaceTrackBlockYaml(args.filePath, args.trackId, args.yaml);
|
||||
const yaml = await fetchYaml(args.filePath, args.trackId);
|
||||
if (yaml === null) return { success: false, error: 'Track vanished after replace' };
|
||||
return { success: true, yaml };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
'track:delete': async (_event, args) => {
|
||||
try {
|
||||
await deleteTrackBlock(args.filePath, args.trackId);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
},
|
||||
// Billing handler
|
||||
'billing:getInfo': async () => {
|
||||
return await getBillingInfo();
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-lin
|
|||
import { splitFrontmatter, joinFrontmatter } from '@/lib/frontmatter'
|
||||
import { OnboardingModal } from '@/components/onboarding'
|
||||
import { CommandPalette, type CommandPaletteContext, type CommandPaletteMention } from '@/components/search-dialog'
|
||||
import { TrackModal } from '@/components/track-modal'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
import { VersionHistoryPanel } from '@/components/version-history-panel'
|
||||
import { FileCardProvider } from '@/contexts/file-card-context'
|
||||
|
|
@ -2687,6 +2688,27 @@ function App() {
|
|||
setPendingPaletteSubmit(null)
|
||||
}, [pendingPaletteSubmit])
|
||||
|
||||
// Listener for track-block "Edit with Copilot" events
|
||||
// (dispatched by apps/renderer/src/extensions/track-block.tsx)
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<{
|
||||
trackId?: string
|
||||
filePath?: string
|
||||
}>
|
||||
const trackId = ev.detail?.trackId
|
||||
const filePath = ev.detail?.filePath
|
||||
if (!trackId || !filePath) return
|
||||
const displayName = filePath.split('/').pop() ?? filePath
|
||||
submitFromPalette(
|
||||
`Let's work on the \`${trackId}\` track in this note. Please load the \`tracks\` skill first, then ask me what I want to change.`,
|
||||
{ path: filePath, displayName },
|
||||
)
|
||||
}
|
||||
window.addEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
|
||||
return () => window.removeEventListener('rowboat:open-copilot-edit-track', handler as EventListener)
|
||||
}, [submitFromPalette])
|
||||
|
||||
const toggleKnowledgePane = useCallback(() => {
|
||||
setIsRightPaneMaximized(false)
|
||||
setIsChatSidebarOpen(prev => !prev)
|
||||
|
|
@ -4560,6 +4582,7 @@ function App() {
|
|||
/>
|
||||
</SidebarSectionProvider>
|
||||
<Toaster />
|
||||
<TrackModal />
|
||||
<OnboardingModal
|
||||
open={showOnboarding}
|
||||
onComplete={handleOnboardingComplete}
|
||||
|
|
|
|||
522
apps/x/apps/renderer/src/components/track-modal.tsx
Normal file
522
apps/x/apps/renderer/src/components/track-modal.tsx
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import '@/styles/track-modal.css'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Radio, Clock, Play, Loader2, Sparkles, Code2, CalendarClock, Zap,
|
||||
Trash2, ChevronDown, ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { TrackBlockSchema, type TrackSchedule } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
import type { OpenTrackModalDetail } from '@/extensions/track-block'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CRON_PHRASES: Record<string, string> = {
|
||||
'* * * * *': 'Every minute',
|
||||
'*/5 * * * *': 'Every 5 minutes',
|
||||
'*/15 * * * *': 'Every 15 minutes',
|
||||
'*/30 * * * *': 'Every 30 minutes',
|
||||
'0 * * * *': 'Hourly',
|
||||
'0 */2 * * *': 'Every 2 hours',
|
||||
'0 */6 * * *': 'Every 6 hours',
|
||||
'0 */12 * * *': 'Every 12 hours',
|
||||
'0 0 * * *': 'Daily at midnight',
|
||||
'0 8 * * *': 'Daily at 8 AM',
|
||||
'0 9 * * *': 'Daily at 9 AM',
|
||||
'0 12 * * *': 'Daily at noon',
|
||||
'0 18 * * *': 'Daily at 6 PM',
|
||||
'0 9 * * 1-5': 'Weekdays at 9 AM',
|
||||
'0 17 * * 1-5': 'Weekdays at 5 PM',
|
||||
'0 0 * * 0': 'Sundays at midnight',
|
||||
'0 0 * * 1': 'Mondays at midnight',
|
||||
'0 0 1 * *': 'First of each month',
|
||||
}
|
||||
|
||||
function describeCron(expr: string): string {
|
||||
return CRON_PHRASES[expr.trim()] ?? expr
|
||||
}
|
||||
|
||||
type ScheduleIconKind = 'timer' | 'calendar' | 'target' | 'bolt'
|
||||
type ScheduleSummary = { icon: ScheduleIconKind; text: string }
|
||||
|
||||
function summarizeSchedule(schedule?: TrackSchedule): ScheduleSummary {
|
||||
if (!schedule) return { icon: 'bolt', text: 'Manual only' }
|
||||
if (schedule.type === 'once') {
|
||||
return { icon: 'target', text: `Once at ${formatDateTime(schedule.runAt)}` }
|
||||
}
|
||||
if (schedule.type === 'cron') {
|
||||
return { icon: 'timer', text: describeCron(schedule.expression) }
|
||||
}
|
||||
if (schedule.type === 'window') {
|
||||
return { icon: 'calendar', text: `${describeCron(schedule.cron)} · ${schedule.startTime}–${schedule.endTime}` }
|
||||
}
|
||||
return { icon: 'calendar', text: 'Scheduled' }
|
||||
}
|
||||
|
||||
function ScheduleIcon({ icon, size = 14 }: { icon: ScheduleIconKind; size?: number }) {
|
||||
if (icon === 'timer') return <Clock size={size} />
|
||||
if (icon === 'calendar' || icon === 'target') return <CalendarClock size={size} />
|
||||
return <Zap size={size} />
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Tab = 'what' | 'when' | 'event' | 'details'
|
||||
|
||||
export function TrackModal() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [detail, setDetail] = useState<OpenTrackModalDetail | null>(null)
|
||||
const [yaml, setYaml] = useState<string>('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('what')
|
||||
const [editingRaw, setEditingRaw] = useState(false)
|
||||
const [rawDraft, setRawDraft] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Listen for the open event and seed modal state.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const ev = e as CustomEvent<OpenTrackModalDetail>
|
||||
const d = ev.detail
|
||||
if (!d?.trackId || !d?.filePath) return
|
||||
setDetail(d)
|
||||
setYaml(d.initialYaml ?? '')
|
||||
setActiveTab('what')
|
||||
setEditingRaw(false)
|
||||
setRawDraft('')
|
||||
setShowAdvanced(false)
|
||||
setConfirmingDelete(false)
|
||||
setError(null)
|
||||
setOpen(true)
|
||||
void fetchFresh(d)
|
||||
}
|
||||
window.addEventListener('rowboat:open-track-modal', handler as EventListener)
|
||||
return () => window.removeEventListener('rowboat:open-track-modal', handler as EventListener)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const fetchFresh = useCallback(async (d: OpenTrackModalDetail) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await window.ipc.invoke('track:get', { trackId: d.trackId, filePath: stripKnowledgePrefix(d.filePath) })
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
if (!yaml) return null
|
||||
try { return TrackBlockSchema.parse(parseYaml(yaml)) } catch { return null }
|
||||
}, [yaml])
|
||||
|
||||
const trackId = track?.trackId ?? detail?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const active = track?.active ?? true
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const lastRunAt = track?.lastRunAt ?? ''
|
||||
const lastRunId = track?.lastRunId ?? ''
|
||||
const lastRunSummary = track?.lastRunSummary ?? ''
|
||||
const scheduleSummary = useMemo(() => summarizeSchedule(schedule), [schedule])
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const knowledgeRelPath = detail ? stripKnowledgePrefix(detail.filePath) : ''
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${trackId}:${knowledgeRelPath}`) ?? { status: 'idle' as const }
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRaw && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length,
|
||||
)
|
||||
}
|
||||
}, [editingRaw])
|
||||
|
||||
const visibleTabs: { key: Tab; label: string; visible: boolean }[] = [
|
||||
{ key: 'what', label: 'What to track', visible: true },
|
||||
{ key: 'when', label: 'When to run', visible: !!schedule },
|
||||
{ key: 'event', label: 'Event matching', visible: !!eventMatchCriteria },
|
||||
{ key: 'details', label: 'Details', visible: true },
|
||||
]
|
||||
const shown = visibleTabs.filter(t => t.visible)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shown.some(t => t.key === activeTab)) setActiveTab('what')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [schedule, eventMatchCriteria])
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// IPC-backed mutations
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
const runUpdate = useCallback(async (updates: Record<string, unknown>) => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:update', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
updates,
|
||||
})
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
const handleToggleActive = useCallback(() => {
|
||||
void runUpdate({ active: !active })
|
||||
}, [active, runUpdate])
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!detail || isRunning) return
|
||||
try {
|
||||
await window.ipc.invoke('track:run', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}, [detail, isRunning])
|
||||
|
||||
const handleSaveRaw = useCallback(async () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:replaceYaml', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
yaml: rawDraft,
|
||||
})
|
||||
if (res?.success && res.yaml) {
|
||||
setYaml(res.yaml)
|
||||
setEditingRaw(false)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail, rawDraft])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!detail) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await window.ipc.invoke('track:delete', {
|
||||
trackId: detail.trackId,
|
||||
filePath: stripKnowledgePrefix(detail.filePath),
|
||||
})
|
||||
if (res?.success) {
|
||||
// Tell the editor to remove the node so Tiptap's next save doesn't
|
||||
// re-create the track block on disk.
|
||||
try { detail.onDeleted() } catch { /* editor may have unmounted */ }
|
||||
setOpen(false)
|
||||
} else if (res?.error) {
|
||||
setError(res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [detail])
|
||||
|
||||
const handleEditWithCopilot = useCallback(() => {
|
||||
if (!detail) return
|
||||
window.dispatchEvent(new CustomEvent('rowboat:open-copilot-edit-track', {
|
||||
detail: {
|
||||
trackId: detail.trackId,
|
||||
filePath: detail.filePath,
|
||||
},
|
||||
}))
|
||||
setOpen(false)
|
||||
}, [detail])
|
||||
|
||||
if (!detail) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
className="track-modal-content w-[min(44rem,calc(100%-2rem))] max-w-2xl p-0 gap-0 overflow-hidden rounded-xl"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
>
|
||||
<div className="track-modal-header">
|
||||
<div className="track-modal-header-left">
|
||||
<div className="track-modal-icon-wrap">
|
||||
<Radio size={16} />
|
||||
</div>
|
||||
<div className="track-modal-title-col">
|
||||
<DialogHeader className="space-y-0">
|
||||
<DialogTitle className="track-modal-title">
|
||||
{trackId || 'Track'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="track-modal-subtitle">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={11} />
|
||||
{scheduleSummary.text}
|
||||
{eventMatchCriteria && triggerType === 'scheduled' && (
|
||||
<span className="track-modal-subtitle-sep">· also event-driven</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
</div>
|
||||
<div className="track-modal-header-actions">
|
||||
<label className="track-modal-toggle">
|
||||
<Switch checked={active} onCheckedChange={handleToggleActive} disabled={saving} />
|
||||
<span className="track-modal-toggle-label">{active ? 'Active' : 'Paused'}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="track-modal-tabs">
|
||||
{shown.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`track-modal-tab ${activeTab === tab.key ? 'track-modal-tab-active' : ''}`}
|
||||
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="track-modal-body">
|
||||
{loading && <div className="track-modal-loading"><Loader2 size={14} className="animate-spin" /> Loading latest…</div>}
|
||||
|
||||
{activeTab === 'what' && (
|
||||
<div className="track-modal-prose">
|
||||
{instruction
|
||||
? <Streamdown className="track-modal-markdown">{instruction}</Streamdown>
|
||||
: <span className="track-modal-empty">No instruction set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'when' && schedule && (
|
||||
<div className="track-modal-when">
|
||||
<div className="track-modal-when-headline">
|
||||
<ScheduleIcon icon={scheduleSummary.icon} size={18} />
|
||||
<span>{scheduleSummary.text}</span>
|
||||
</div>
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Type</dt><dd><code>{schedule.type}</code></dd>
|
||||
{schedule.type === 'cron' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.expression}</code></dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'window' && (
|
||||
<>
|
||||
<dt>Expression</dt><dd><code>{schedule.cron}</code></dd>
|
||||
<dt>Window</dt><dd>{schedule.startTime} – {schedule.endTime}</dd>
|
||||
</>
|
||||
)}
|
||||
{schedule.type === 'once' && (
|
||||
<>
|
||||
<dt>Runs at</dt><dd>{formatDateTime(schedule.runAt)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'event' && (
|
||||
<div className="track-modal-prose">
|
||||
{eventMatchCriteria
|
||||
? <Streamdown className="track-modal-markdown">{eventMatchCriteria}</Streamdown>
|
||||
: <span className="track-modal-empty">No event matching set.</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-details">
|
||||
<dl className="track-modal-dl">
|
||||
<dt>Track ID</dt><dd><code>{trackId}</code></dd>
|
||||
<dt>File</dt><dd><code>{detail.filePath}</code></dd>
|
||||
<dt>Status</dt><dd>{active ? 'Active' : 'Paused'}</dd>
|
||||
{lastRunAt && (<>
|
||||
<dt>Last run</dt><dd>{formatDateTime(lastRunAt)}</dd>
|
||||
</>)}
|
||||
{lastRunId && (<>
|
||||
<dt>Run ID</dt><dd><code>{lastRunId}</code></dd>
|
||||
</>)}
|
||||
{lastRunSummary && (<>
|
||||
<dt>Summary</dt><dd>{lastRunSummary}</dd>
|
||||
</>)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced (raw YAML) — all tabs */}
|
||||
<div className="track-modal-advanced">
|
||||
<button
|
||||
className="track-modal-advanced-toggle"
|
||||
onClick={() => {
|
||||
const next = !showAdvanced
|
||||
setShowAdvanced(next)
|
||||
if (next) {
|
||||
setRawDraft(yaml)
|
||||
setEditingRaw(true)
|
||||
} else {
|
||||
setEditingRaw(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
<Code2 size={12} />
|
||||
Advanced (raw YAML)
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="track-modal-raw-editor">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
rows={12}
|
||||
spellCheck={false}
|
||||
className="track-modal-textarea"
|
||||
/>
|
||||
<div className="track-modal-raw-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { setRawDraft(yaml); setShowAdvanced(false); setEditingRaw(false) }}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveRaw}
|
||||
disabled={saving || rawDraft.trim() === yaml.trim()}
|
||||
>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : null}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger zone — on Details tab only */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="track-modal-danger-zone">
|
||||
{confirmingDelete ? (
|
||||
<div className="track-modal-confirm">
|
||||
<span>Delete this track and its generated content?</span>
|
||||
<div className="track-modal-confirm-actions">
|
||||
<Button variant="outline" size="sm" onClick={() => setConfirmingDelete(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={saving}>
|
||||
{saving ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
|
||||
Yes, delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="track-modal-delete-btn"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Delete track block
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="track-modal-error">{error}</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="track-modal-footer">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEditWithCopilot}
|
||||
disabled={saving}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
Edit with Copilot
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={isRunning || saving}
|
||||
className="track-modal-run-btn"
|
||||
>
|
||||
{isRunning ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
|
||||
{isRunning ? 'Running…' : 'Run now'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function stripKnowledgePrefix(p: string): string {
|
||||
return p.replace(/^knowledge\//, '')
|
||||
}
|
||||
|
|
@ -1,43 +1,41 @@
|
|||
import { z } from 'zod'
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { mergeAttributes, Node } from '@tiptap/react'
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
|
||||
import { Radio, ChevronRight, X, Clock, Code2, Check, Play, Loader2 } from 'lucide-react'
|
||||
import { Radio, Loader2 } from 'lucide-react'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import { TrackBlockSchema } from '@x/shared/dist/track-block.js'
|
||||
import { useTrackStatus } from '@/hooks/use-track-status'
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
const clean = text.replace(/\s+/g, ' ').trim()
|
||||
if (clean.length <= maxLen) return clean
|
||||
return clean.slice(0, maxLen).trimEnd() + '…'
|
||||
}
|
||||
|
||||
type Tab = 'metadata' | 'instruction' | 'criteria'
|
||||
function TrackBlockView({ node, deleteNode, updateAttributes, extension }: {
|
||||
// Detail shape for the open-track-modal window event. Defined here so the
|
||||
// consumer (TrackModal) can import it without a circular dependency.
|
||||
export type OpenTrackModalDetail = {
|
||||
trackId: string
|
||||
/** Workspace-relative path, e.g. "knowledge/Notes/foo.md" */
|
||||
filePath: string
|
||||
/** Best-effort initial YAML from Tiptap's cached node attr (modal refetches fresh). */
|
||||
initialYaml: string
|
||||
/** Invoked after a successful IPC delete so the editor can remove the node. */
|
||||
onDeleted: () => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chip (display-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TrackBlockView({ node, deleteNode, extension }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
updateAttributes: (attrs: Record<string, unknown>) => void
|
||||
extension: { options: { notePath?: string } }
|
||||
}) {
|
||||
const raw = node.attrs.data as string
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('instruction')
|
||||
const [editingRaw, setEditingRaw] = useState(false)
|
||||
const [rawDraft, setRawDraft] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const track = useMemo<z.infer<typeof TrackBlockSchema> | null>(() => {
|
||||
try {
|
||||
|
|
@ -47,232 +45,76 @@ function TrackBlockView({ node, deleteNode, updateAttributes, extension }: {
|
|||
|
||||
const trackId = track?.trackId ?? ''
|
||||
const instruction = track?.instruction ?? ''
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const active = track?.active ?? true
|
||||
const lastRunAt = track?.lastRunAt ?? ''
|
||||
const lastRunId = track?.lastRunId ?? ''
|
||||
const lastRunSummary = track?.lastRunSummary ?? ''
|
||||
const schedule = track?.schedule
|
||||
const eventMatchCriteria = track?.eventMatchCriteria ?? ''
|
||||
const notePath = extension.options.notePath
|
||||
const trackFilePath = notePath?.replace(/^knowledge\//, '') ?? ''
|
||||
|
||||
// Track run status from the global hook
|
||||
const triggerType: 'scheduled' | 'event' | 'manual' =
|
||||
schedule ? 'scheduled' : eventMatchCriteria ? 'event' : 'manual'
|
||||
|
||||
const allTrackStatus = useTrackStatus()
|
||||
const runState = allTrackStatus.get(`${track?.trackId}:${trackFilePath}`) ?? { status: 'idle' as const }
|
||||
const runStatus = runState.status
|
||||
const runSummary = runState.summary ?? runState.error ?? null
|
||||
const isRunning = runState.status === 'running'
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRaw && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.setSelectionRange(
|
||||
textareaRef.current.value.length,
|
||||
textareaRef.current.value.length,
|
||||
)
|
||||
}
|
||||
}, [editingRaw])
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setRawDraft(raw)
|
||||
setEditingRaw(true)
|
||||
}
|
||||
|
||||
const handleSaveRaw = () => {
|
||||
updateAttributes({ data: rawDraft })
|
||||
setEditingRaw(false)
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingRaw(false)
|
||||
}
|
||||
|
||||
const handleRun = useCallback(async (e: React.MouseEvent) => {
|
||||
const handleOpen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (runStatus === 'running' || !trackId || !trackFilePath) return
|
||||
try {
|
||||
await window.ipc.invoke('track:run', { trackId, filePath: trackFilePath })
|
||||
} catch (err) {
|
||||
console.error('[TrackBlock] Run failed:', err)
|
||||
if (!trackId || !notePath) return
|
||||
const detail: OpenTrackModalDetail = {
|
||||
trackId,
|
||||
filePath: notePath,
|
||||
initialYaml: raw,
|
||||
onDeleted: () => deleteNode(),
|
||||
}
|
||||
}, [runStatus, trackId, trackFilePath])
|
||||
window.dispatchEvent(new CustomEvent<OpenTrackModalDetail>(
|
||||
'rowboat:open-track-modal',
|
||||
{ detail },
|
||||
))
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'instruction', label: 'Instruction' },
|
||||
{ key: 'criteria', label: 'Match Criteria' },
|
||||
{ key: 'metadata', label: 'Metadata' },
|
||||
]
|
||||
|
||||
const isRunning = runStatus === 'running'
|
||||
const handleKey = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleOpen(e as unknown as React.MouseEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="track-block-wrapper" data-type="track-block">
|
||||
<div
|
||||
className={`track-block-card ${!active ? 'track-block-paused' : ''} ${isRunning ? 'track-block-running' : ''}`}
|
||||
<NodeViewWrapper
|
||||
className="track-block-chip-wrapper"
|
||||
data-type="track-block"
|
||||
data-trigger={triggerType}
|
||||
data-active={active ? 'true' : 'false'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`track-block-chip ${!active ? 'track-block-chip-paused-state' : ''} ${isRunning ? 'track-block-chip-running' : ''}`}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={handleKey}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title={instruction ? `${trackId}: ${instruction}` : trackId}
|
||||
>
|
||||
<button
|
||||
className="track-block-delete"
|
||||
onClick={deleteNode}
|
||||
aria-label="Delete track block"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
|
||||
{/* Collapsed view */}
|
||||
<div
|
||||
className="track-block-collapsed"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`track-block-chevron ${expanded ? 'track-block-chevron-open' : ''}`}
|
||||
/>
|
||||
<Radio size={14} className="track-block-icon" />
|
||||
<span className="track-block-label">Track</span>
|
||||
{!active && <span className="track-block-badge track-block-badge-paused">paused</span>}
|
||||
<span className="track-block-summary">{truncate(instruction, 60)}</span>
|
||||
{lastRunAt && !isRunning && (
|
||||
<span className="track-block-meta">
|
||||
<Clock size={11} />
|
||||
{formatDateTime(lastRunAt)}
|
||||
</span>
|
||||
)}
|
||||
{isRunning && (
|
||||
<span className="track-block-meta track-block-meta-running">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
Running…
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className="track-block-run-btn"
|
||||
onClick={handleRun}
|
||||
disabled={isRunning}
|
||||
aria-label="Run track"
|
||||
title="Run track"
|
||||
>
|
||||
{isRunning
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <Play size={13} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
{runSummary && runStatus !== 'running' && (
|
||||
<div className={`track-block-status-bar ${runStatus === 'error' ? 'track-block-status-error' : 'track-block-status-done'}`}>
|
||||
{runSummary}
|
||||
</div>
|
||||
{isRunning
|
||||
? <Loader2 size={13} className="animate-spin track-block-chip-icon" />
|
||||
: <Radio size={13} className="track-block-chip-icon" />}
|
||||
<span className="track-block-chip-id">{trackId || 'track'}</span>
|
||||
{instruction && (
|
||||
<span className="track-block-chip-sep">·</span>
|
||||
)}
|
||||
|
||||
{/* Expanded view */}
|
||||
{expanded && (
|
||||
<div className="track-block-expanded">
|
||||
<div className="track-block-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`track-block-tab ${activeTab === tab.key ? 'track-block-tab-active' : ''}`}
|
||||
onClick={() => { setActiveTab(tab.key); setEditingRaw(false) }}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={`track-block-tab track-block-tab-raw ${editingRaw ? 'track-block-tab-active' : ''}`}
|
||||
onClick={handleStartEdit}
|
||||
>
|
||||
<Code2 size={12} />
|
||||
Edit Raw
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingRaw ? (
|
||||
<div className="track-block-raw-editor">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="track-block-textarea"
|
||||
value={rawDraft}
|
||||
onChange={(e) => setRawDraft(e.target.value)}
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleCancelEdit()
|
||||
}
|
||||
if (e.key === 's' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleSaveRaw()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="track-block-raw-actions">
|
||||
<button className="track-block-btn track-block-btn-secondary" onClick={handleCancelEdit}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="track-block-btn track-block-btn-primary" onClick={handleSaveRaw}>
|
||||
<Check size={12} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="track-block-panel">
|
||||
{activeTab === 'instruction' && (
|
||||
<div className="track-block-panel-text">
|
||||
{instruction
|
||||
? <Streamdown className="track-block-markdown">{instruction}</Streamdown>
|
||||
: <span className="track-block-empty">No instruction set</span>}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'criteria' && (
|
||||
<div className="track-block-panel-text">
|
||||
{eventMatchCriteria
|
||||
? <Streamdown className="track-block-markdown">{eventMatchCriteria}</Streamdown>
|
||||
: <span className="track-block-empty">No event match criteria set</span>}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'metadata' && (
|
||||
<div className="track-block-metadata-grid">
|
||||
<div className="track-block-metadata-row">
|
||||
<span className="track-block-metadata-label">Track ID</span>
|
||||
<span className="track-block-metadata-value"><code>{trackId}</code></span>
|
||||
</div>
|
||||
<div className="track-block-metadata-row">
|
||||
<span className="track-block-metadata-label">Status</span>
|
||||
<span className="track-block-metadata-value">
|
||||
<span className={`track-block-badge ${active ? 'track-block-badge-active' : 'track-block-badge-paused'}`}>
|
||||
{active ? 'active' : 'paused'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
{lastRunAt && (
|
||||
<div className="track-block-metadata-row">
|
||||
<span className="track-block-metadata-label">Last run</span>
|
||||
<span className="track-block-metadata-value">{formatDateTime(lastRunAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{lastRunId && (
|
||||
<div className="track-block-metadata-row">
|
||||
<span className="track-block-metadata-label">Run ID</span>
|
||||
<span className="track-block-metadata-value"><code>{lastRunId}</code></span>
|
||||
</div>
|
||||
)}
|
||||
{lastRunSummary && (
|
||||
<div className="track-block-metadata-row">
|
||||
<span className="track-block-metadata-label">Summary</span>
|
||||
<span className="track-block-metadata-value">{lastRunSummary}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{instruction && (
|
||||
<span className="track-block-chip-instruction">{truncate(instruction, 80)}</span>
|
||||
)}
|
||||
</div>
|
||||
{!active && <span className="track-block-chip-paused-label">paused</span>}
|
||||
</button>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiptap extension — unchanged schema, parseHTML, serialize
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const TrackBlockExtension = Node.create({
|
||||
name: 'trackBlock',
|
||||
group: 'block',
|
||||
|
|
|
|||
|
|
@ -611,381 +611,110 @@
|
|||
.tiptap-editor .ProseMirror .task-block-last-run {
|
||||
color: color-mix(in srgb, var(--foreground) 38%, transparent);
|
||||
}
|
||||
/* =============================================================
|
||||
Track Block — inline chip (display-only)
|
||||
The chip just opens a modal (TrackModal). All mutations live in the
|
||||
modal and go through IPC, so the editor never writes track state.
|
||||
============================================================= */
|
||||
|
||||
/* Track block styles */
|
||||
.tiptap-editor .ProseMirror .track-block-wrapper {
|
||||
margin: 8px 0;
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper {
|
||||
--track-accent: #64748b; /* default: manual/slate */
|
||||
margin: 4px 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-card {
|
||||
position: relative;
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--primary);
|
||||
border-radius: 8px;
|
||||
background-color: color-mix(in srgb, var(--primary) 4%, transparent);
|
||||
cursor: default;
|
||||
transition: background-color 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-card.track-block-paused {
|
||||
border-left-color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
background-color: color-mix(in srgb, var(--muted) 30%, transparent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-card:hover {
|
||||
background-color: color-mix(in srgb, var(--primary) 8%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-wrapper.ProseMirror-selectednode .track-block-card {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Delete button */
|
||||
.tiptap-editor .ProseMirror .track-block-delete {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-card:hover .track-block-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-delete:hover {
|
||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Collapsed row */
|
||||
.tiptap-editor .ProseMirror .track-block-collapsed {
|
||||
display: flex;
|
||||
.tiptap-editor .ProseMirror .track-block-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 32px 10px 12px;
|
||||
max-width: 100%;
|
||||
padding: 6px 12px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: var(--foreground);
|
||||
background: color-mix(in srgb, var(--track-accent) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--track-accent) 35%, transparent);
|
||||
border-left: 3px solid var(--track-accent);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
transition: background-color 0.12s ease, box-shadow 0.12s ease, transform 0.06s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chevron {
|
||||
.tiptap-editor .ProseMirror .track-block-chip:hover {
|
||||
background: color-mix(in srgb, var(--track-accent) 14%, transparent);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--track-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:active {
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip:focus-visible {
|
||||
outline: 2px solid var(--track-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-paused-state {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-running {
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 40%, transparent);
|
||||
animation: track-chip-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes track-chip-pulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--track-accent) 35%, transparent); }
|
||||
50% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--track-accent) 15%, transparent); }
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chip-icon {
|
||||
flex-shrink: 0;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
transition: transform 0.15s ease;
|
||||
color: var(--track-accent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-chevron-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-icon {
|
||||
color: var(--primary);
|
||||
.tiptap-editor .ProseMirror .track-block-chip-id {
|
||||
font-weight: 600;
|
||||
color: var(--track-accent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--primary);
|
||||
.tiptap-editor .ProseMirror .track-block-chip-sep {
|
||||
color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-summary {
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
.tiptap-editor .ProseMirror .track-block-chip-instruction {
|
||||
color: color-mix(in srgb, var(--foreground) 80%, transparent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
.tiptap-editor .ProseMirror .track-block-chip-paused-label {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Re-run button */
|
||||
.tiptap-editor .ProseMirror .track-block-run-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, color 0.1s ease, background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-card:hover .track-block-run-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-run-btn:hover {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-run-btn:disabled {
|
||||
opacity: 1;
|
||||
color: var(--primary);
|
||||
cursor: default;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Running state */
|
||||
.tiptap-editor .ProseMirror .track-block-card.track-block-running {
|
||||
border-left-color: color-mix(in srgb, var(--primary) 70%, transparent);
|
||||
animation: track-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes track-pulse {
|
||||
0%, 100% { background-color: color-mix(in srgb, var(--primary) 4%, transparent); }
|
||||
50% { background-color: color-mix(in srgb, var(--primary) 10%, transparent); }
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-meta-running {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.tiptap-editor .ProseMirror .track-block-status-bar {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
border-top: 1px solid var(--border);
|
||||
animation: track-status-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes track-status-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-status-done {
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-status-error {
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.tiptap-editor .ProseMirror .track-block-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-badge-active {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-badge-paused {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Expanded area */
|
||||
.tiptap-editor .ProseMirror .track-block-expanded {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tiptap-editor .ProseMirror .track-block-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-tab {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s ease, border-color 0.1s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-tab:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-tab-active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-tab-raw {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Tab panel content */
|
||||
.tiptap-editor .ProseMirror .track-block-panel {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-panel-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--foreground);
|
||||
white-space: pre-wrap;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-empty {
|
||||
color: color-mix(in srgb, var(--foreground) 35%, transparent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-markdown {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-markdown > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-markdown > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Metadata grid */
|
||||
.tiptap-editor .ProseMirror .track-block-metadata-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-metadata-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-metadata-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
min-width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-metadata-value {
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-metadata-value code {
|
||||
font-size: 12px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
/* Raw editor */
|
||||
.tiptap-editor .ProseMirror .track-block-raw-editor {
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-textarea {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
line-height: 1.5;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
color: var(--foreground);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-textarea:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-raw-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-btn-secondary {
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-btn-secondary:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 14%, transparent);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror .track-block-btn-primary:hover {
|
||||
background: color-mix(in srgb, var(--primary) 85%, black);
|
||||
.tiptap-editor .ProseMirror .track-block-chip-wrapper.ProseMirror-selectednode .track-block-chip {
|
||||
outline: 2px solid var(--track-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Shared block styles (image, embed, chart, table) */
|
||||
|
|
|
|||
311
apps/x/apps/renderer/src/styles/track-modal.css
Normal file
311
apps/x/apps/renderer/src/styles/track-modal.css
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/* =============================================================
|
||||
Track Modal — dialog overlay for track block details / edits
|
||||
============================================================= */
|
||||
|
||||
.track-modal-content {
|
||||
--track-accent: #64748b;
|
||||
}
|
||||
|
||||
.track-modal-content[data-trigger="scheduled"] { --track-accent: #6366f1; }
|
||||
.track-modal-content[data-trigger="event"] { --track-accent: #a855f7; }
|
||||
.track-modal-content[data-trigger="manual"] { --track-accent: #64748b; }
|
||||
.track-modal-content[data-active="false"] { --track-accent: color-mix(in srgb, var(--foreground) 30%, transparent); }
|
||||
|
||||
/* Header */
|
||||
.track-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--track-accent) 6%, transparent);
|
||||
border-left: 4px solid var(--track-accent);
|
||||
}
|
||||
|
||||
.track-modal-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-modal-icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--track-accent) 15%, transparent);
|
||||
color: var(--track-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-modal-title-col {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.track-modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
.track-modal-subtitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: color-mix(in srgb, var(--foreground) 60%, transparent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.track-modal-subtitle-sep {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.track-modal-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.track-modal-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track-modal-toggle-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 70%, transparent);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.track-modal-tabs {
|
||||
display: flex;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.track-modal-tab {
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s ease, border-color 0.1s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track-modal-tab:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.track-modal-tab-active {
|
||||
color: var(--track-accent);
|
||||
border-bottom-color: var(--track-accent);
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.track-modal-body {
|
||||
padding: 18px 20px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.track-modal-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: color-mix(in srgb, var(--foreground) 50%, transparent);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.track-modal-prose {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.track-modal-markdown {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.track-modal-markdown > *:first-child { margin-top: 0; }
|
||||
.track-modal-markdown > *:last-child { margin-bottom: 0; }
|
||||
|
||||
.track-modal-empty {
|
||||
color: color-mix(in srgb, var(--foreground) 40%, transparent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* When-to-run panel */
|
||||
.track-modal-when {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.track-modal-when-headline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--track-accent);
|
||||
padding: 12px 14px;
|
||||
background: color-mix(in srgb, var(--track-accent) 10%, transparent);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--track-accent);
|
||||
}
|
||||
|
||||
/* Description list (Details / When) */
|
||||
.track-modal-dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 16px;
|
||||
row-gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.track-modal-dl dt {
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.track-modal-dl dd {
|
||||
margin: 0;
|
||||
color: var(--foreground);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.track-modal-dl code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
padding: 1px 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Advanced / raw YAML disclosure */
|
||||
.track-modal-advanced {
|
||||
margin-top: 20px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-advanced-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track-modal-advanced-toggle:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.track-modal-raw-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.track-modal-textarea {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.track-modal-raw-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Danger zone */
|
||||
.track-modal-danger-zone {
|
||||
margin-top: 20px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--destructive, #ef4444) 20%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-delete-btn {
|
||||
color: color-mix(in srgb, var(--destructive, #ef4444) 85%, var(--foreground));
|
||||
border-color: color-mix(in srgb, var(--destructive, #ef4444) 30%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-delete-btn:hover {
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 10%, transparent);
|
||||
color: var(--destructive, #ef4444);
|
||||
}
|
||||
|
||||
.track-modal-confirm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--destructive, #ef4444) 25%, transparent);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.track-modal-confirm-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.track-modal-error {
|
||||
margin: 0 20px 14px 20px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--destructive, #ef4444) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--destructive, #ef4444) 25%, transparent);
|
||||
color: var(--destructive, #ef4444);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.track-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
}
|
||||
|
||||
.track-modal-run-btn {
|
||||
background: var(--track-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.track-modal-run-btn:hover {
|
||||
background: color-mix(in srgb, var(--track-accent) 85%, black);
|
||||
}
|
||||
|
|
@ -69,6 +69,17 @@ export async function fetch(filePath: string, trackId: string): Promise<z.infer<
|
|||
return blocks.find(b => b.track.trackId === trackId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a track block and return its canonical YAML string (or null if not found).
|
||||
* Useful for IPC handlers that need to return the fresh YAML without taking a
|
||||
* dependency on the `yaml` package themselves.
|
||||
*/
|
||||
export async function fetchYaml(filePath: string, trackId: string): Promise<string | null> {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) return null;
|
||||
return stringifyYaml(block.track).trimEnd();
|
||||
}
|
||||
|
||||
export async function updateContent(filePath: string, trackId: string, newContent: string): Promise<void> {
|
||||
let content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const openTag = `<!--track-target:${trackId}-->`;
|
||||
|
|
@ -106,4 +117,74 @@ export async function updateTrackBlock(filepath: string, trackId: string, update
|
|||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
content = lines.join('\n');
|
||||
await fs.writeFile(absPath(filepath), content, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire YAML of a track block on disk with a new string.
|
||||
* Unlike updateTrackBlock (which merges), this writes the raw YAML verbatim —
|
||||
* used when the user explicitly edits raw YAML in the modal.
|
||||
* The new YAML must still parse to a valid TrackBlock with a matching trackId,
|
||||
* otherwise the write is rejected.
|
||||
*/
|
||||
export async function replaceTrackBlockYaml(filePath: string, trackId: string, newYaml: string): Promise<void> {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
throw new Error(`Track ${trackId} not found in ${filePath}`);
|
||||
}
|
||||
const parsed = TrackBlockSchema.safeParse(parseYaml(newYaml));
|
||||
if (!parsed.success) {
|
||||
throw new Error(`Invalid track YAML: ${parsed.error.message}`);
|
||||
}
|
||||
if (parsed.data.trackId !== trackId) {
|
||||
throw new Error(`trackId cannot be changed (was "${trackId}", got "${parsed.data.trackId}")`);
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const yamlLines = newYaml.trimEnd().split('\n');
|
||||
lines.splice(block.fenceStart, block.fenceEnd - block.fenceStart + 1, '```track', ...yamlLines, '```');
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a track block and its sibling target region from the file.
|
||||
*/
|
||||
export async function deleteTrackBlock(filePath: string, trackId: string): Promise<void> {
|
||||
const block = await fetch(filePath, trackId);
|
||||
if (!block) {
|
||||
// Already gone — treat as success.
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await fs.readFile(absPath(filePath), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const openTag = `<!--track-target:${trackId}-->`;
|
||||
const closeTag = `<!--/track-target:${trackId}-->`;
|
||||
|
||||
// Find target region (may not exist)
|
||||
let targetStart = -1;
|
||||
let targetEnd = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes(openTag)) { targetStart = i; }
|
||||
if (targetStart !== -1 && lines[i].includes(closeTag)) { targetEnd = i; break; }
|
||||
}
|
||||
|
||||
// Build a list of [start, end] ranges to remove, sorted descending so
|
||||
// indices stay valid as we splice.
|
||||
const ranges: Array<[number, number]> = [];
|
||||
ranges.push([block.fenceStart, block.fenceEnd]);
|
||||
if (targetStart !== -1 && targetEnd !== -1 && targetEnd >= targetStart) {
|
||||
ranges.push([targetStart, targetEnd]);
|
||||
}
|
||||
ranges.sort((a, b) => b[0] - a[0]);
|
||||
|
||||
for (const [start, end] of ranges) {
|
||||
lines.splice(start, end - start + 1);
|
||||
// Also drop a trailing blank line if the removal left two in a row.
|
||||
if (start < lines.length && lines[start].trim() === '' && start > 0 && lines[start - 1].trim() === '') {
|
||||
lines.splice(start, 1);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(absPath(filePath), lines.join('\n'), 'utf-8');
|
||||
}
|
||||
|
|
@ -577,6 +577,55 @@ const ipcSchemas = {
|
|||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:get': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
// Fresh, authoritative YAML of the track block from disk.
|
||||
// Renderer should use this for display/edit — never its Tiptap node attr.
|
||||
yaml: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:update': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
// Partial TrackBlock updates — merged into the block's YAML on disk.
|
||||
// Backend is the sole writer; avoids races with scheduler/runner writes.
|
||||
updates: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
yaml: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:replaceYaml': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
yaml: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
yaml: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'track:delete': {
|
||||
req: z.object({
|
||||
trackId: z.string(),
|
||||
filePath: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
// Billing channels
|
||||
'billing:getInfo': {
|
||||
req: z.null(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue