This commit is contained in:
Jarek Krochmalski committed 2026-01-11 09:01:42 +01:00
1 parent c15355e159
commit 851e56bc57
23 files changed
+8923

No files matched your search

+394
View File
@@ -0,0 +1,394 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, FolderOpen, File, FileText, ChevronRight, ArrowUp, AlertCircle, FolderPlus, Search, Import } from 'lucide-svelte';
import type { Component } from 'svelte';
import RecentLocationsPanel from './RecentLocationsPanel.svelte';
export interface FileEntry {
name: string;
path: string;
type: 'file' | 'directory' | 'symlink';
size: number;
mtime: string;
mode: string;
}
interface Props {
open: boolean;
title?: string;
/** Optional icon component to display before the title */
icon?: Component<{ class?: string }>;
description?: string;
initialPath?: string;
selectFilter?: RegExp;
selectMode?: 'file' | 'directory' | 'file_or_directory' | 'adopt';
/** For adopt mode: filter to highlight (e.g., /\.ya?ml$/i for compose files) */
highlightFilter?: RegExp;
/** For adopt mode: called when user clicks on a matching file */
onFilePreview?: (entry: FileEntry) => void;
/** For adopt mode: called when user clicks "Scan this folder" */
onScanDirectory?: (path: string) => void;
/** For adopt mode: show loading state on scan button */
scanning?: boolean;
onSelect: (path: string, name: string) => void;
onClose: () => void;
}
let {
open = $bindable(false),
title = 'Select file',
icon,
description,
initialPath = '/',
selectFilter,
selectMode = 'file',
highlightFilter,
onFilePreview,
onScanDirectory,
scanning = false,
onSelect,
onClose
}: Props = $props();
let currentPath = $state<string | null>(null);
let entries = $state<FileEntry[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
// Track selected file
let selectedPath = $state<string | null>(null);
let selectedName = $state<string | null>(null);
// Reference to recent locations panel
let recentLocationsPanel = $state<{ addLocation: (path: string) => Promise<void>; getFirstLocation: () => string | null } | null>(null);
// Expose methods for parent to call
export function getCurrentPath(): string | null {
return currentPath;
}
export function addRecentLocation(path: string): Promise<void> {
return recentLocationsPanel?.addLocation(path) ?? Promise.resolve();
}
// Load directory when dialog opens
$effect(() => {
if (open && !currentPath) {
// Wait a tick for the panel to load, then use first location or initialPath
setTimeout(() => {
const firstLocation = recentLocationsPanel?.getFirstLocation();
loadDirectory(firstLocation || initialPath);
}, 50);
}
});
function handleRecentSelect(path: string) {
loadDirectory(path);
}
async function loadDirectory(path: string) {
loading = true;
error = null;
try {
const res = await fetch(`/api/system/files?path=${encodeURIComponent(path)}`);
const data = await res.json();
if (!res.ok) {
error = data.error || 'Failed to load directory';
return;
}
currentPath = data.path;
entries = data.entries;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load directory';
} finally {
loading = false;
}
}
function handleEntryClick(entry: FileEntry, doubleClick: boolean = false) {
if (selectMode === 'adopt') {
// Adopt mode: click on directory navigates, click on highlighted file triggers preview
if (entry.type === 'directory') {
loadDirectory(entry.path);
} else if (highlightFilter?.test(entry.name) && onFilePreview) {
onFilePreview(entry);
}
return;
}
if (entry.type === 'directory') {
if (selectMode === 'file_or_directory' && !doubleClick) {
// Single click on directory in file_or_directory mode - select it
selectedPath = entry.path;
selectedName = entry.name;
} else {
// Double click or other modes - navigate into directory
selectedPath = null;
selectedName = null;
loadDirectory(entry.path);
}
} else if (selectMode === 'file' || selectMode === 'file_or_directory') {
// Select file
selectedPath = entry.path;
selectedName = entry.name;
}
}
function handleGoUp() {
if (!currentPath || currentPath === '/') return;
const parent = currentPath.replace(/\/[^/]+$/, '') || '/';
selectedPath = null;
selectedName = null;
loadDirectory(parent);
}
function handleConfirm() {
if (selectMode === 'directory' && currentPath) {
// In directory mode, select the current directory
const name = currentPath === '/' ? '/' : currentPath.split('/').pop() || '';
onSelect(currentPath, name);
handleClose();
} else if (selectedPath && selectedName) {
// In file or file_or_directory mode, select the selected item
onSelect(selectedPath, selectedName);
handleClose();
} else if (selectMode === 'file_or_directory' && currentPath) {
// In file_or_directory mode with nothing selected, use current directory
const name = currentPath === '/' ? '/' : currentPath.split('/').pop() || '';
onSelect(currentPath, name);
handleClose();
}
}
function handleClose() {
currentPath = null;
entries = [];
selectedPath = null;
selectedName = null;
error = null;
open = false;
onClose();
}
function handleScan() {
if (currentPath && onScanDirectory) {
onScanDirectory(currentPath);
}
}
function isSelectable(entry: FileEntry): boolean {
if (selectMode === 'adopt') {
// In adopt mode, nothing is "selectable" in the traditional sense
return false;
}
if (selectMode === 'directory') {
return entry.type === 'directory';
}
if (selectMode === 'file_or_directory') {
// Directories are always selectable, files must match filter
if (entry.type === 'directory') return true;
if (!selectFilter) return true;
return selectFilter.test(entry.name);
}
// File mode
if (entry.type === 'directory') return false;
if (!selectFilter) return true;
return selectFilter.test(entry.name);
}
function isHighlighted(entry: FileEntry): boolean {
if (entry.type === 'directory') return false;
if (!highlightFilter) return false;
return highlightFilter.test(entry.name);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
const canGoUp = $derived(currentPath && currentPath !== '/');
// In directory mode, only show directories; otherwise show all
const filteredEntries = $derived(
selectMode === 'directory'
? entries.filter(e => e.type === 'directory')
: entries
);
const isAdoptMode = $derived(selectMode === 'adopt');
</script>
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen) handleClose(); }}>
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col {isAdoptMode ? 'p-0 gap-0' : ''}">
<Dialog.Header class={isAdoptMode ? 'px-6 py-4 border-b shrink-0' : ''}>
<Dialog.Title class="flex items-center gap-2">
{#if icon}
<svelte:component this={icon} class="w-5 h-5" />
{/if}
{title}
</Dialog.Title>
{#if description}
<Dialog.Description>{description}</Dialog.Description>
{/if}
</Dialog.Header>
<div class="flex-1 overflow-hidden flex {isAdoptMode ? 'min-h-0' : ''}">
<!-- Recent locations sidebar -->
<RecentLocationsPanel
bind:this={recentLocationsPanel}
{currentPath}
onSelect={handleRecentSelect}
/>
<!-- Main browser area -->
<div class="flex-1 flex flex-col min-h-0">
<!-- Path bar -->
<div class="flex items-center gap-2 px-4 py-2 border-b bg-muted/30">
<button
type="button"
class="p-1 rounded hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!canGoUp}
onclick={handleGoUp}
title="Go up"
>
<ArrowUp class="w-4 h-4" />
</button>
<code class="text-xs bg-muted px-2 py-1 rounded truncate flex-1">{currentPath || '/'}</code>
{#if isAdoptMode}
<Button
variant="default"
size="sm"
onclick={handleScan}
disabled={scanning || !currentPath}
>
{#if scanning}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Scanning...
{:else}
<Search class="w-4 h-4 mr-2" />
Scan this folder
{/if}
</Button>
{/if}
</div>
<!-- File list -->
<div class="flex-1 overflow-auto">
{#if loading}
<div class="flex items-center justify-center py-12">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
{:else if error}
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<div class="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-4">
<AlertCircle class="w-8 h-8 text-red-500" />
</div>
<p class="text-red-600 dark:text-red-400 font-medium">Unable to browse files</p>
<p class="text-sm text-muted-foreground mt-1">{error}</p>
<Button variant="outline" size="sm" class="mt-4" onclick={() => currentPath && loadDirectory(currentPath)}>
Retry
</Button>
</div>
{:else if filteredEntries.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
<FolderOpen class="w-12 h-12 mb-3 opacity-50" />
<p>{selectMode === 'directory' ? 'No subdirectories' : 'Directory is empty'}</p>
</div>
{:else}
<div class="divide-y">
{#each filteredEntries as entry}
{@const selectable = isSelectable(entry)}
{@const highlighted = isHighlighted(entry)}
<button
type="button"
class="w-full flex items-center gap-3 px-4 {isAdoptMode ? 'py-1.5' : 'py-2'} hover:bg-muted/50 text-left transition-colors
{entry.type === 'directory' ? 'cursor-pointer' : (selectable || highlighted) ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}
{selectedPath === entry.path ? 'bg-blue-50 dark:bg-blue-900/20' : ''}"
onclick={() => (entry.type === 'directory' || selectable || highlighted) && handleEntryClick(entry, false)}
ondblclick={() => entry.type === 'directory' && handleEntryClick(entry, true)}
disabled={entry.type !== 'directory' && !selectable && !highlighted}
>
{#if entry.type === 'directory'}
<FolderOpen class="w-4 h-4 text-blue-500 shrink-0" />
{:else if highlighted}
<FileText class="w-4 h-4 text-green-500 shrink-0" />
{:else}
<File class="w-4 h-4 text-zinc-400 shrink-0 {selectable ? 'text-green-500' : ''}" />
{/if}
<span class="flex-1 truncate {isAdoptMode ? 'text-xs' : 'text-sm'} {(selectable || highlighted) && entry.type !== 'directory' ? 'text-green-600 dark:text-green-400 font-medium' : ''}">
{entry.name}
</span>
{#if highlighted && isAdoptMode}
<Badge variant="secondary" class="text-xs">Compose file</Badge>
{:else if entry.type !== 'directory' && !isAdoptMode}
<span class="text-xs text-muted-foreground">{formatSize(entry.size)}</span>
{/if}
{#if entry.type === 'directory'}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
{#if !isAdoptMode}
<Dialog.Footer class="border-t pt-4">
{#if selectMode === 'directory'}
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="text-xs text-muted-foreground shrink-0">Selected:</span>
<code class="text-xs font-mono bg-muted px-2 py-1 rounded truncate" title={currentPath || '/'}>{currentPath || '/'}</code>
</div>
{:else if selectMode === 'file_or_directory'}
<div class="flex-1 flex items-center gap-2 min-w-0">
{#if selectedPath}
<span class="text-xs text-muted-foreground shrink-0">Selected:</span>
<code class="text-xs font-mono bg-muted px-2 py-1 rounded truncate" title={selectedPath}>{selectedPath}</code>
{:else}
<span class="text-xs text-muted-foreground">Click to select file or folder, double-click to enter folder</span>
{/if}
</div>
{:else if selectedPath}
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="text-xs text-muted-foreground shrink-0">Selected:</span>
<code class="text-xs font-mono bg-muted px-2 py-1 rounded truncate" title={selectedPath}>{selectedPath}</code>
</div>
{:else}
<div class="flex-1 text-xs text-muted-foreground">
Click a file to select it
</div>
{/if}
<Button variant="outline" onclick={handleClose}>
Cancel
</Button>
{#if selectMode === 'directory'}
<Button onclick={handleConfirm}>
<FolderPlus class="w-4 h-4 mr-2" />
Select
</Button>
{:else if selectMode === 'file_or_directory'}
<Button onclick={handleConfirm}>
Select
</Button>
{:else}
<Button
disabled={!selectedPath}
onclick={handleConfirm}
>
Select
</Button>
{/if}
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>
+494
View File
@@ -0,0 +1,494 @@
<script lang="ts">
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Import, Loader2, Play, Info } from 'lucide-svelte';
import FilesystemBrowser, { type FileEntry } from './FilesystemBrowser.svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import { toast } from 'svelte-sonner';
import { currentEnvironment, environments } from '$lib/stores/environment';
import { getIconComponent } from '$lib/utils/icons';
interface DiscoveredStack {
name: string;
composePath: string;
envPath: string | null;
sourceDir?: string;
serviceCount?: number;
running?: boolean;
containerCount?: number;
}
interface Props {
open: boolean;
onClose: () => void;
onAdopted?: () => void;
}
let { open = $bindable(), onClose, onAdopted }: Props = $props();
// View state: 'browse' | 'results'
let view = $state<'browse' | 'results'>('browse');
// Reference to filesystem browser
let filesystemBrowser = $state<{ getCurrentPath: () => string | null; addRecentLocation: (path: string) => Promise<void> } | null>(null);
// Scan results state
let scanResults = $state<DiscoveredStack[]>([]);
let scanning = $state(false);
// Selection and adopt state
let stackSelections = $state<Map<string, boolean>>(new Map());
let adopting = $state(false);
// Preview dialog state (for single file click)
let showPreview = $state(false);
let previewFile = $state<FileEntry | null>(null);
let previewContent = $state<string | null>(null);
let previewServiceCount = $state<number>(0);
let loadingPreview = $state(false);
// Use current environment from store
const envId = $derived($currentEnvironment?.id ?? null);
const envName = $derived($currentEnvironment?.name ?? 'Unknown');
// Look up the icon from the environments list since currentEnvironment doesn't store it
const currentEnvData = $derived($environments.find(e => e.id === envId));
const envIcon = $derived(currentEnvData?.icon || 'globe');
const EnvIconComponent = $derived(getIconComponent(envIcon));
// Reset when modal closes
$effect(() => {
if (!open) {
view = 'browse';
scanResults = [];
stackSelections = new Map();
showPreview = false;
previewFile = null;
previewContent = null;
previewServiceCount = 0;
}
});
async function handleFilePreview(entry: FileEntry) {
previewFile = entry;
showPreview = true;
loadingPreview = true;
previewContent = null;
previewServiceCount = 0;
try {
const res = await fetch(`/api/system/files/content?path=${encodeURIComponent(entry.path)}`);
if (res.ok) {
const data = await res.json();
previewContent = data.content || '';
// Count services in the compose file
const serviceMatches = previewContent?.match(/^services:\s*\n((?:\s{2,}\w+:.*\n?)+)/m);
if (serviceMatches) {
const servicesBlock = serviceMatches[1];
const serviceNames = servicesBlock.match(/^\s{2}\w+:/gm);
previewServiceCount = serviceNames?.length || 0;
}
}
} catch (e) {
console.error('Failed to load preview:', e);
} finally {
loadingPreview = false;
}
}
function confirmAdoptFromPreview() {
if (previewFile) {
adoptSingleFile(previewFile);
showPreview = false;
}
}
async function handleScanDirectory(path: string) {
scanning = true;
try {
const res = await fetch('/api/stacks/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to scan directory');
return;
}
const discovered: DiscoveredStack[] = data.discovered || [];
// Detect running stacks on current environment
if (envId && discovered.length > 0) {
try {
const runningRes = await fetch(`/api/stacks?env=${envId}`);
if (runningRes.ok) {
const runningStacks: Array<{ name: string; containers?: any[] }> = await runningRes.json();
const runningMap = new Map(
runningStacks.map((s) => [s.name.toLowerCase(), s])
);
for (const stack of discovered) {
const runningStack = runningMap.get(stack.name.toLowerCase());
if (runningStack) {
stack.running = true;
stack.containerCount = runningStack.containers?.length || 0;
}
}
}
} catch (e) {
console.error('Failed to detect running stacks:', e);
}
}
scanResults = discovered;
if (discovered.length === 0) {
toast.info('No compose stacks found in this directory');
} else {
const selections = new Map<string, boolean>();
for (const stack of discovered) {
// Don't pre-select stacks that are already running on this environment
selections.set(stack.composePath, !stack.running);
}
stackSelections = selections;
view = 'results';
}
await filesystemBrowser?.addRecentLocation(path);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to scan directory');
} finally {
scanning = false;
}
}
async function adoptSingleFile(entry: FileEntry) {
if (!envId) {
toast.error('No environment selected');
return;
}
adopting = true;
try {
const parentDir = entry.path.replace(/\/[^/]+$/, '');
const stackName = parentDir.split('/').pop() || 'adopted-stack';
const envFilePath = `${parentDir}/.env`;
const stack: DiscoveredStack = {
name: stackName,
composePath: entry.path,
envPath: envFilePath,
sourceDir: parentDir
};
const res = await fetch('/api/stacks/adopt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stacks: [stack],
environmentId: envId
})
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to adopt stack');
return;
}
if (data.adopted?.length > 0) {
toast.success(`Adopted stack "${data.adopted[0]}"`);
await filesystemBrowser?.addRecentLocation(parentDir);
onAdopted?.();
handleClose();
} else if (data.failed?.length > 0) {
toast.error(`Failed: ${data.failed[0].error}`);
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to adopt');
} finally {
adopting = false;
}
}
async function handleAdoptSelected() {
if (!envId || stackSelections.size === 0) return;
const selectedStacks = scanResults.filter(s => stackSelections.get(s.composePath));
if (selectedStacks.length === 0) return;
adopting = true;
try {
const res = await fetch('/api/stacks/adopt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stacks: selectedStacks,
environmentId: envId
})
});
const data = await res.json();
if (!res.ok) {
toast.error(data.error || 'Failed to adopt stacks');
return;
}
if (data.adopted?.length > 0) {
toast.success(`Adopted ${data.adopted.length} stack(s)`);
onAdopted?.();
handleClose();
}
if (data.failed?.length > 0) {
toast.error(`Failed to adopt ${data.failed.length} stack(s)`);
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to adopt');
} finally {
adopting = false;
}
}
function toggleStack(composePath: string) {
const newMap = new Map(stackSelections);
newMap.set(composePath, !newMap.get(composePath));
stackSelections = newMap;
}
function toggleAll() {
const allSelected = scanResults.every(s => stackSelections.get(s.composePath));
const newMap = new Map<string, boolean>();
for (const stack of scanResults) {
newMap.set(stack.composePath, !allSelected);
}
stackSelections = newMap;
}
function handleClose() {
open = false;
onClose();
}
function goBackToBrowse() {
view = 'browse';
scanResults = [];
stackSelections = new Map();
}
const selectedCount = $derived([...stackSelections.values()].filter(v => v).length);
const allSelected = $derived(scanResults.length > 0 && scanResults.every(s => stackSelections.get(s.composePath)));
// Browser title with environment info
const browserTitle = $derived.by(() => {
const envPart = envName ? ` · ${envName}` : '';
return `Adopt stacks${envPart}`;
});
</script>
{#if view === 'browse'}
<!-- File Browser View - using FilesystemBrowser component -->
<FilesystemBrowser
bind:this={filesystemBrowser}
bind:open
title={browserTitle}
icon={Import}
description="Browse to a compose file or scan a directory for stacks."
selectMode="adopt"
highlightFilter={/\.ya?ml$/i}
onFilePreview={handleFilePreview}
onScanDirectory={handleScanDirectory}
{scanning}
onSelect={() => {}}
onClose={handleClose}
/>
{:else}
<!-- Scan Results View -->
<Dialog.Root bind:open onOpenChange={(o) => !o && handleClose()}>
<Dialog.Content class="max-w-4xl h-[80vh] flex flex-col p-0 gap-0">
<Dialog.Header class="px-6 py-4 border-b shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Import class="w-5 h-5" />
Select stacks to adopt
<span class="text-muted-foreground">·</span>
<EnvIconComponent class="w-4 h-4 text-muted-foreground" />
<span class="text-muted-foreground font-normal">{envName}</span>
</Dialog.Title>
<Dialog.Description>
{scanResults.length} stack(s) found. Select which ones to adopt into {envName}.
</Dialog.Description>
</Dialog.Header>
<div class="flex-1 flex flex-col min-h-0">
<!-- Stack list -->
<div class="flex-1 overflow-y-auto p-4">
<div class="space-y-2">
{#each scanResults as stack}
{@const isSelected = stackSelections.get(stack.composePath)}
{@const countsMismatch = stack.running && stack.serviceCount && stack.containerCount !== stack.serviceCount}
<div
class="flex items-start gap-3 p-3 rounded-lg border {isSelected ? 'border-primary/50 bg-primary/5' : 'border-border'}"
>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleStack(stack.composePath)}
class="mt-0.5"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium truncate">{stack.name}</span>
{#if stack.serviceCount}
<Badge variant="outline" class="text-xs">
{stack.serviceCount} service{stack.serviceCount !== 1 ? 's' : ''}
</Badge>
{/if}
{#if stack.running}
<Badge variant="default" class="text-xs {countsMismatch ? 'bg-amber-600' : 'bg-green-600'}">
<Play class="w-3 h-3 mr-1" />
{stack.containerCount} running
</Badge>
{/if}
</div>
<p class="text-xs text-muted-foreground truncate mt-0.5" title={stack.composePath}>
{stack.composePath}
</p>
{#if stack.envPath}
<p class="text-xs text-muted-foreground truncate" title={stack.envPath}>
.env: {stack.envPath}
</p>
{/if}
</div>
</div>
{/each}
</div>
</div>
<!-- Adopt info -->
<div class="px-4 py-3 border-t shrink-0">
<div class="flex items-start gap-2.5 text-xs bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-md px-3 py-2.5">
<Info class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<span><span class="font-medium text-amber-600 dark:text-amber-400">What happens when you adopt:</span> <span class="text-zinc-600 dark:text-zinc-400">Dockhand will track these compose files, letting you edit, start, and stop the stacks from the UI. Your files stay in their current location.</span></span>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t flex items-center justify-between shrink-0">
<div class="flex items-center gap-3">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
/>
<span class="text-sm text-muted-foreground">
<span class="font-medium text-foreground">{selectedCount}</span> of {scanResults.length} selected
</span>
</div>
<div class="flex gap-2">
<Button variant="outline" onclick={goBackToBrowse}>
Back
</Button>
<Button variant="outline" onclick={handleClose}>
Cancel
</Button>
<Button
variant="default"
onclick={handleAdoptSelected}
disabled={adopting || selectedCount === 0}
>
{#if adopting}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Adopting...
{:else}
<Import class="w-4 h-4 mr-2" />
Adopt {selectedCount} stack(s)
{/if}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
{/if}
<!-- Preview dialog for single file adopt -->
<Dialog.Root bind:open={showPreview}>
<Dialog.Content class="max-w-3xl h-[70vh] flex flex-col p-0 gap-0">
<Dialog.Header class="px-5 py-4 border-b shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Import class="w-5 h-5" />
Adopt this stack?
</Dialog.Title>
<Dialog.Description>
Review the compose file before adopting.
</Dialog.Description>
</Dialog.Header>
{#if previewFile}
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- Stack info bar -->
<div class="px-5 py-3 border-b bg-muted/30 flex items-center gap-4 shrink-0">
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">Stack:</span>
<span class="font-medium">{previewFile.path.replace(/\/[^/]+$/, '').split('/').pop() || 'unknown'}</span>
{#if previewServiceCount > 0}
<Badge variant="outline" class="text-xs">
{previewServiceCount} service{previewServiceCount !== 1 ? 's' : ''}
</Badge>
{/if}
</div>
<div class="flex-1 min-w-0">
<code class="text-xs bg-muted px-2 py-1 rounded truncate block">{previewFile.path}</code>
</div>
</div>
<!-- Preview content with syntax highlighting -->
<div class="flex-1 min-h-0 p-3">
{#if loadingPreview}
<div class="flex items-center justify-center h-full">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
{:else if previewContent}
<CodeEditor
value={previewContent}
language="yaml"
theme="dark"
readonly={true}
class="h-full rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
/>
{/if}
</div>
<!-- Adopt info -->
<div class="px-5 py-3 border-t shrink-0">
<div class="flex items-start gap-2.5 text-xs bg-zinc-100 dark:bg-zinc-800/50 border border-zinc-200 dark:border-zinc-700 rounded-md px-3 py-2.5">
<Info class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<span><span class="font-medium text-amber-600 dark:text-amber-400">What happens when you adopt:</span> <span class="text-zinc-600 dark:text-zinc-400">Dockhand will track this compose file, letting you edit, start, and stop the stack from the UI. Your files stay in their current location.</span></span>
</div>
</div>
</div>
{/if}
<div class="px-5 py-3 border-t flex justify-end gap-2 shrink-0">
<Button variant="outline" onclick={() => showPreview = false}>
Cancel
</Button>
<Button onclick={confirmAdoptFromPreview} disabled={adopting}>
{#if adopting}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
Adopting...
{:else}
<Import class="w-4 h-4 mr-2" />
Adopt stack
{/if}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
import { Copy, Check, FolderOpen, FolderSync } from 'lucide-svelte';
interface Props {
label: string;
path: string | null;
placeholder: string;
onCopy: () => void;
onBrowse: () => void;
onChangeLocation?: () => void; // Optional: relocate entire folder
defaultText?: string;
isSuggested?: boolean;
copied?: boolean;
sourceHint?: string; // e.g., "Using default location"
}
let {
label,
path,
placeholder,
onCopy,
onBrowse,
onChangeLocation,
defaultText = 'Default location',
isSuggested = false,
copied = false,
sourceHint
}: Props = $props();
// Truncate long paths - only truncate if really necessary
function truncatePath(pathStr: string, maxLength: number = 80): { truncated: string; full: string } {
if (!pathStr || pathStr.length <= maxLength) return { truncated: pathStr, full: pathStr };
const parts = pathStr.split('/');
const filename = parts.pop() || '';
const parent = parts.pop() || '';
const grandparent = parts.pop() || '';
// Try to show more context: .../{grandparent}/{parent}/{filename}
let truncated = `.../${grandparent}/${parent}/${filename}`;
if (truncated.length > maxLength) {
// Fall back to just parent/filename
truncated = `.../${parent}/${filename}`;
}
return { truncated, full: pathStr };
}
const displayPath = $derived(path ? truncatePath(path) : { truncated: '', full: '' });
</script>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2 text-xs text-zinc-500 dark:text-zinc-400">
<span class="font-medium text-zinc-600 dark:text-zinc-300 shrink-0">{label}</span>
<code
class="flex-1 min-w-0 truncate font-mono h-6 leading-6 {!path || isSuggested ? 'text-zinc-400 dark:text-zinc-500 italic' : ''}"
title={displayPath.full}
>
{displayPath.truncated || defaultText}
</code>
<button
onclick={onBrowse}
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>
{#if onChangeLocation}
<button
onclick={onChangeLocation}
class="p-1 rounded transition-colors shrink-0 hover:bg-zinc-200 dark:hover:bg-zinc-700"
title="Change location"
>
<FolderSync class="w-3.5 h-3.5" />
</button>
{/if}
<button
onclick={onCopy}
class="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-colors shrink-0 {!path ? 'opacity-40 cursor-not-allowed' : ''}"
title="Copy path"
disabled={!path}
>
{#if copied}
<Check class="w-3.5 h-3.5 text-green-500" />
{:else}
<Copy class="w-3.5 h-3.5" />
{/if}
</button>
</div>
{#if sourceHint}
<span class="text-[10px] text-zinc-400 dark:text-zinc-500 pl-[4.5rem]">{sourceHint}</span>
{/if}
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import { FolderOpen, X, Home } from 'lucide-svelte';
interface Props {
currentPath?: string | null;
onSelect: (path: string) => void;
}
let {
currentPath = null,
onSelect
}: Props = $props();
let locations = $state<string[]>([]);
let defaultBasePath = $state<string | null>(null);
// Load recent locations and default base path on mount
$effect(() => {
loadLocations();
loadDefaultBasePath();
});
async function loadDefaultBasePath() {
try {
const response = await fetch('/api/stacks/base-path');
if (response.ok) {
const data = await response.json();
defaultBasePath = data.basePath;
}
} catch (e) {
console.error('Failed to load default base path:', e);
}
}
async function loadLocations() {
try {
const response = await fetch('/api/settings/general');
if (response.ok) {
const settings = await response.json();
locations = settings.externalStackPaths || [];
}
} catch (e) {
console.error('Failed to load recent locations:', e);
}
}
async function saveLocations(newLocations: string[]) {
try {
await fetch('/api/settings/general', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ externalStackPaths: newLocations })
});
} catch (e) {
console.error('Failed to save recent locations:', e);
}
}
async function handleRemove(path: string) {
const newLocations = locations.filter(p => p !== path);
locations = newLocations;
await saveLocations(newLocations);
}
export async function addLocation(path: string) {
if (!path || locations.includes(path)) return;
const newLocations = [path, ...locations].slice(0, 10);
locations = newLocations;
await saveLocations(newLocations);
}
export function getFirstLocation(): string | null {
return locations[0] || null;
}
</script>
<div class="w-56 border-r p-3 overflow-y-auto shrink-0">
<h3 class="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">Locations</h3>
<div class="space-y-1">
<!-- Default Dockhand location (always shown, not removable) -->
{#if defaultBasePath}
<button
type="button"
class="w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-muted text-left {currentPath === defaultBasePath ? 'bg-muted' : ''}"
onclick={() => onSelect(defaultBasePath!)}
>
<Home class="w-4 h-4 shrink-0 text-sky-500" />
<span class="truncate" title={defaultBasePath}>Dockhand default</span>
</button>
{/if}
<!-- Recent locations -->
{#each locations.filter(l => l !== defaultBasePath) as location}
<div class="group flex items-center gap-1">
<button
type="button"
class="flex-1 flex items-center gap-2 px-2 py-1.5 rounded text-xs hover:bg-muted text-left truncate {currentPath === location ? 'bg-muted' : ''}"
onclick={() => onSelect(location)}
>
<FolderOpen class="w-4 h-4 shrink-0 text-muted-foreground" />
<span class="truncate" title={location}>{location.split('/').pop() || location}</span>
</button>
<button
type="button"
class="p-1 opacity-0 group-hover:opacity-100 hover:bg-muted rounded transition-opacity"
onclick={() => handleRemove(location)}
title="Remove from recent"
>
<X class="w-3 h-3 text-muted-foreground" />
</button>
</div>
{/each}
{#if !defaultBasePath && locations.length === 0}
<p class="text-xs text-muted-foreground italic px-2">
No locations yet. Browse folders to add them here.
</p>
{/if}
</div>
</div>