Allow viewing Git stack YAML

Open Git-sourced stack compose files in a read-only editor and graph while keeping repository settings separate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nenad StojanovikjandCursor authored and Jarek Krochmalski committed 2026-07-21 05:49:51 +02:00
1 parent c718ac9a84
commit 9f09d60a83
4 files changed
+66 -33

No files matched your search

+15 -7
View File
@@ -65,6 +65,7 @@
let showGitModal = $state(false); let showGitModal = $state(false);
let showImportModal = $state(false); let showImportModal = $state(false);
let editingStackName = $state(''); let editingStackName = $state('');
let stackModalReadonly = $state(false);
let editingGitStack = $state<any>(null); let editingGitStack = $state<any>(null);
let envId = $state<number | null>(null); let envId = $state<number | null>(null);
@@ -1048,6 +1049,13 @@
function editStack(name: string) { function editStack(name: string) {
editingStackName = name; editingStackName = name;
stackModalReadonly = false;
showEditModal = true;
}
function viewGitStack(name: string) {
editingStackName = name;
stackModalReadonly = true;
showEditModal = true; showEditModal = true;
} }
@@ -1593,19 +1601,17 @@
{#if column.id === 'name'} {#if column.id === 'name'}
{@const systemType = getStackSystemType(stack)} {@const systemType = getStackSystemType(stack)}
<span class="flex items-center gap-1 min-w-0 w-full"> <span class="flex items-center gap-1 min-w-0 w-full">
{#if source.sourceType !== 'git'}
<!-- Internal stacks (including those needing file location) are clickable -->
<button <button
type="button" type="button"
class="font-medium text-xs hover:text-primary hover:underline cursor-pointer text-left truncate min-w-0" class="font-medium text-xs hover:text-primary hover:underline cursor-pointer text-left truncate min-w-0"
onclick={(e) => { e.stopPropagation(); editStack(stack.name); }} onclick={(e) => {
e.stopPropagation();
if (source.sourceType === 'git') viewGitStack(stack.name);
else editStack(stack.name);
}}
> >
{stack.name} {stack.name}
</button> </button>
{:else}
<!-- Git stacks open in GitStackModal instead -->
<span class="font-medium text-xs truncate min-w-0">{stack.name}</span>
{/if}
{#if systemType} {#if systemType}
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
@@ -2515,9 +2521,11 @@
bind:open={showEditModal} bind:open={showEditModal}
mode="edit" mode="edit"
stackName={editingStackName} stackName={editingStackName}
readonly={stackModalReadonly}
onClose={() => { onClose={() => {
showEditModal = false; showEditModal = false;
editingStackName = ''; editingStackName = '';
stackModalReadonly = false;
}} }}
onSuccess={fetchStacks} onSuccess={fetchStacks}
/> />
+5 -2
View File
@@ -19,9 +19,10 @@
composeContent: string; composeContent: string;
class?: string; class?: string;
onContentChange?: (content: string) => void; onContentChange?: (content: string) => void;
readonly?: boolean;
} }
let { composeContent, class: className = '', onContentChange }: Props = $props(); let { composeContent, class: className = '', onContentChange, readonly = false }: Props = $props();
let containerEl: HTMLDivElement | null = $state(null); let containerEl: HTMLDivElement | null = $state(null);
let cy: cytoscape.Core | null = null; let cy: cytoscape.Core | null = null;
@@ -2103,6 +2104,7 @@
<!-- Toolbar --> <!-- Toolbar -->
<div class="flex items-center justify-between px-2 py-1.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 min-h-[40px]"> <div class="flex items-center justify-between px-2 py-1.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 min-h-[40px]">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
{#if !readonly}
<!-- Add element dropdown --> <!-- Add element dropdown -->
<div class="relative"> <div class="relative">
<Button <Button
@@ -2198,6 +2200,7 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{/if}
</div> </div>
<!-- Controls --> <!-- Controls -->
@@ -2333,7 +2336,7 @@
</div> </div>
</div> </div>
<!-- Details panel (overlay) --> <!-- Details panel (overlay) -->
{#if selectedNode || selectedEdge} {#if !readonly && (selectedNode || selectedEdge)}
<div class="absolute top-0 right-0 bottom-0 w-[420px] border-l border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95 shadow-lg z-20 flex flex-col"> <div class="absolute top-0 right-0 bottom-0 w-[420px] border-l border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/95 shadow-lg z-20 flex flex-col">
<!-- Sticky header --> <!-- Sticky header -->
{#if selectedNode} {#if selectedNode}
+10 -8
View File
@@ -7,7 +7,7 @@
path: string | null; path: string | null;
placeholder: string; placeholder: string;
onCopy: () => void; onCopy: () => void;
onBrowse: () => void; onBrowse?: () => void;
onChangeLocation?: () => void; // Optional: relocate entire folder onChangeLocation?: () => void; // Optional: relocate entire folder
defaultText?: string; defaultText?: string;
isSuggested?: boolean; isSuggested?: boolean;
@@ -58,13 +58,15 @@
> >
{displayPath.truncated || defaultText} {displayPath.truncated || defaultText}
</code> </code>
<button {#if onBrowse}
onclick={onBrowse} <button
class="p-1 rounded transition-colors shrink-0 hover:bg-zinc-200 dark:hover:bg-zinc-700" onclick={onBrowse}
title={`Browse for ${label.toLowerCase()}`} class="p-1 rounded transition-colors shrink-0 hover:bg-zinc-200 dark:hover:bg-zinc-700"
> title={`Browse for ${label.toLowerCase()}`}
<FolderOpen class="w-3.5 h-3.5" /> >
</button> <FolderOpen class="w-3.5 h-3.5" />
</button>
{/if}
{#if onChangeLocation} {#if onChangeLocation}
<button <button
onclick={onChangeLocation} onclick={onChangeLocation}
+36 -16
View File
@@ -37,11 +37,12 @@
stackName?: string; // Required for edit mode, optional for create stackName?: string; // Required for edit mode, optional for create
initialCompose?: string; // Pre-fill compose content (for library deploy) initialCompose?: string; // Pre-fill compose content (for library deploy)
initialStackName?: string; // Pre-fill stack name (for library deploy) initialStackName?: string; // Pre-fill stack name (for library deploy)
readonly?: boolean; // View compose content without allowing local changes
onClose: () => void; onClose: () => void;
onSuccess: () => void; // Called after create or save onSuccess: () => void; // Called after create or save
} }
let { open = $bindable(), mode: propMode, stackName: propStackName = '', initialCompose, initialStackName, onClose, onSuccess }: Props = $props(); let { open = $bindable(), mode: propMode, stackName: propStackName = '', initialCompose, initialStackName, readonly = false, onClose, onSuccess }: Props = $props();
// Local effective state - can transition from create → edit after failed deploy // Local effective state - can transition from create → edit after failed deploy
let mode = $state(propMode); let mode = $state(propMode);
@@ -1363,6 +1364,8 @@
<Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400"> <Dialog.Description class="text-xs text-zinc-500 dark:text-zinc-400">
{#if mode === 'create'} {#if mode === 'create'}
Create a new Docker Compose stack Create a new Docker Compose stack
{:else if readonly}
View compose file and dependency graph
{:else} {:else}
Edit compose file and environment variables Edit compose file and environment variables
{/if} {/if}
@@ -1454,7 +1457,7 @@
{/if} {/if}
<!-- File location needed banner --> <!-- File location needed banner -->
{#if mode === 'edit' && needsFileLocation} {#if mode === 'edit' && needsFileLocation && !readonly}
<div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 bg-amber-50/50 dark:bg-amber-950/20"> <div class="px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 bg-amber-50/50 dark:bg-amber-950/20">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<AlertCircle class="w-4 h-4 shrink-0 text-amber-500 mt-0.5" /> <AlertCircle class="w-4 h-4 shrink-0 text-amber-500 mt-0.5" />
@@ -1493,8 +1496,8 @@
placeholder="/path/to/compose.yaml" placeholder="/path/to/compose.yaml"
copied={composePathCopied} copied={composePathCopied}
onCopy={() => copyText(workingComposePath, (v) => composePathCopied = v)} onCopy={() => copyText(workingComposePath, (v) => composePathCopied = v)}
onBrowse={openComposeBrowser} onBrowse={readonly ? undefined : openComposeBrowser}
onChangeLocation={mode === 'edit' && !needsFileLocation ? openChangeLocationBrowser : undefined} onChangeLocation={mode === 'edit' && !needsFileLocation && !readonly ? openChangeLocationBrowser : undefined}
defaultText={mode === 'create' ? 'Enter stack name above' : 'Not specified'} defaultText={mode === 'create' ? 'Enter stack name above' : 'Not specified'}
sourceHint={pathSourceHint} sourceHint={pathSourceHint}
/> />
@@ -1510,8 +1513,8 @@
placeholder="/path/to/.env (optional)" placeholder="/path/to/.env (optional)"
copied={envPathCopied} copied={envPathCopied}
onCopy={() => copyText(displayEnvPath, (v) => envPathCopied = v)} onCopy={() => copyText(displayEnvPath, (v) => envPathCopied = v)}
onBrowse={openEnvBrowser} onBrowse={readonly ? undefined : openEnvBrowser}
isEditable={true} isEditable={!readonly}
isCustom={!!workingEnvPath} isCustom={!!workingEnvPath}
defaultText={mode === 'create' ? 'Enter stack name above' : 'Not specified'} defaultText={mode === 'create' ? 'Enter stack name above' : 'Not specified'}
isSuggested={isEnvPathSuggested} isSuggested={isEnvPathSuggested}
@@ -1528,7 +1531,15 @@
<div class="flex-shrink-0 flex flex-col min-w-0" style="width: {splitRatio}%"> <div class="flex-shrink-0 flex flex-col min-w-0" style="width: {splitRatio}%">
{#if open} {#if open}
<div class="flex-1 p-3 min-h-0"> <div class="flex-1 p-3 min-h-0">
{#if needsFileLocation && !composeContent} {#if readonly && needsFileLocation && !composeContent}
<div class="h-full rounded-md border border-dashed border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800/30 flex flex-col items-center justify-center text-center px-8">
<GitGraph class="w-12 h-12 text-zinc-300 dark:text-zinc-600 mb-4" />
<h3 class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">Compose file not available</h3>
<p class="text-xs text-zinc-500 dark:text-zinc-400 max-w-sm">
Deploy or sync this Git stack first so Dockhand has a local copy of its compose file.
</p>
</div>
{:else if needsFileLocation && !composeContent}
<!-- Empty state for untracked stacks --> <!-- Empty state for untracked stacks -->
<div class="h-full rounded-md border border-dashed border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800/30 flex flex-col items-center justify-center text-center px-8"> <div class="h-full rounded-md border border-dashed border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800/30 flex flex-col items-center justify-center text-center px-8">
<FolderOpen class="w-12 h-12 text-zinc-300 dark:text-zinc-600 mb-4" /> <FolderOpen class="w-12 h-12 text-zinc-300 dark:text-zinc-600 mb-4" />
@@ -1578,8 +1589,9 @@
bind:this={codeEditorRef} bind:this={codeEditorRef}
value={composeContent} value={composeContent}
language="yaml" language="yaml"
{readonly}
theme={editorTheme} theme={editorTheme}
onchange={handleComposeChange} onchange={readonly ? undefined : handleComposeChange}
variableMarkers={variableMarkers} variableMarkers={variableMarkers}
class="flex-1 rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700" class="flex-1 rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
/> />
@@ -1608,6 +1620,7 @@
bind:rawContent={rawEnvContent} bind:rawContent={rawEnvContent}
validation={envValidation} validation={envValidation}
existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()} existingSecretKeys={mode === 'edit' ? existingSecretKeys : new Set()}
{readonly}
onchange={() => { markDirty(); debouncedValidate(); }} onchange={() => { markDirty(); debouncedValidate(); }}
theme={editorTheme} theme={editorTheme}
infoText="These variables will be written to a .env file in the stack directory and passed to the compose command." infoText="These variables will be written to a .env file in the stack directory and passed to the compose command."
@@ -1618,9 +1631,10 @@
<!-- Graph tab: Full width --> <!-- Graph tab: Full width -->
<ComposeGraphViewer <ComposeGraphViewer
bind:this={graphViewerRef} bind:this={graphViewerRef}
composeContent={composeContent || defaultCompose} composeContent={composeContent || (mode === 'create' ? defaultCompose : '')}
class="h-full flex-1" class="h-full flex-1"
onContentChange={handleGraphContentChange} onContentChange={readonly ? undefined : handleGraphContentChange}
{readonly}
/> />
{/if} {/if}
</div> </div>
@@ -1630,7 +1644,9 @@
<!-- Footer --> <!-- Footer -->
<div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0"> <div class="px-5 py-2.5 border-t border-zinc-200 dark:border-zinc-700 flex items-center justify-between flex-shrink-0">
<div class="text-xs text-zinc-500 dark:text-zinc-400"> <div class="text-xs text-zinc-500 dark:text-zinc-400">
{#if isDirty} {#if readonly}
Read-only
{:else if isDirty}
<span class="text-amber-600 dark:text-amber-500">Unsaved changes</span> <span class="text-amber-600 dark:text-amber-500">Unsaved changes</span>
{:else} {:else}
No changes No changes
@@ -1638,11 +1654,15 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button variant="outline" onclick={tryClose} disabled={saving}> {#if readonly}
Cancel <Button onclick={tryClose}>Close</Button>
</Button> {:else}
<Button variant="outline" onclick={tryClose} disabled={saving}>
Cancel
</Button>
{/if}
{#if mode === 'create'} {#if !readonly && mode === 'create'}
<!-- Create mode buttons --> <!-- Create mode buttons -->
<Button variant="outline" onclick={() => handleCreate(false)} disabled={saving}> <Button variant="outline" onclick={() => handleCreate(false)} disabled={saving}>
{#if saving} {#if saving}
@@ -1662,7 +1682,7 @@
Create & Start Create & Start
{/if} {/if}
</Button> </Button>
{:else} {:else if !readonly}
<!-- Edit mode buttons --> <!-- Edit mode buttons -->
<Button variant="outline" class="w-24" onclick={() => handleSave(false)} disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}> <Button variant="outline" class="w-24" onclick={() => handleSave(false)} disabled={saving || loading || (needsFileLocation && !workingComposePath.trim())}>
{#if saving && !savingWithRestart} {#if saving && !savingWithRestart}