mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 20:27:22 +00:00
feat: ordered multi-file Compose for Git sources (#1380)
* feat: ordered multi-file Compose for Git sources
Extend Git sources to deploy an ordered list of compose files merged with
docker compose -f base.yaml -f override.yaml ..., plus an optional project
directory.
- Pick and reorder compose files from the repository tree (drag to reorder on
desktop, up/down arrows on phones); manual path entry is also supported.
- The ordered set drives every stack-scoped compose command (deploy, update,
start/stop/restart/down, image scans, Compose Doctor) and the container
lookup, so a service or image declared only in an override is handled too.
- Runtime keys off the materialized set, not the saved configuration: saving a
source does not change deploy args until the pull is applied, and apply
materializes from the pending snapshot rather than live config.
- The project directory is passed as --project-directory, with -p <stack>
pinning the Compose project so container labels stay stable.
- The Mesh override is layered last; single-file sources are byte-identical to
before, and existing rows keep working via the single-path fallback.
Docs cover the picker, ordering, project directory, and the new troubleshooting
and limitations (referenced files are not materialized; the dependency graph,
drift, and networking views read the primary file).
* fix: harden multi-file Git source (hash, unlink, collisions, node id)
- hashContent folds ordered file CONTENTS (not paths) so a clean multi-file
stack is not flagged as locally edited: create/apply hash the fetched files
(repo paths) while pull hashes the on-disk files (materialized paths), which
previously disagreed and showed a false "local edits detected".
- Block unlinking a multi-file or project-directory Git source (409): the deploy
spec lives on the source row, so removing it would silently revert deploys to
root compose.yaml. Single-file sources still unlink.
- Reject materialized-path collisions in the selection validator: an additional
file equal to or nested under compose.yaml, an ancestor/descendant overlap
between selected files, and a project directory nested under a compose file
(previously a 500 at materialization).
- DockerController.getContainersByStack uses the controller's node compose dir
and passes its node id to the authored prefix, instead of the process default.
* fix: CI failures on multi-file Git source (test crash, aria query, path barrier)
- GitSourceFields no longer crashes when repoUrl/branch are falsy: the canBrowse
trim() is optional-chained, so a reusable field component tolerates partial
props. Fixes the apply-binding panel test, which feeds a minimal source object.
- GitSourcePanel tests query the footer Remove button by its exact name, so the
picker's per-file "Remove <path>" buttons no longer collide with the broad
/remove/i match (the test intent, footer Remove present/absent, is unchanged).
- validateCompose uses an inline resolve + startsWith barrier at the context-dir
mkdir sink (CodeQL does not credit the wrapped isPathWithinBase helper),
clearing the js/path-injection alert. The containment check is equivalent and
contextDir is also validated upstream.
* test: update Git source E2E spec for the multi-file compose picker
The compose-file picker replaced the single #git-source-path input and added
per-file Remove buttons, so the E2E spec drove selectors that no longer exist:
- Drop the redundant compose.yaml fills (the picker defaults to compose.yaml).
- Select the footer Remove button by exact name so the picker's per-file
"Remove <path>" buttons no longer make the locator ambiguous.
- Set a custom compose path through the picker (add via the manual input, press
Enter, then remove the default compose.yaml).
* test: match the footer Remove button with an exact Playwright name
Playwright's getByRole name option is a substring match by default, so
{ name: 'Remove' } also matched the picker's "Remove <path>" buttons. Require an
exact match so only the footer Remove button is selected.
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
import { useState } from 'react';
|
||||
import { GripVertical, ArrowUp, ArrowDown, X, FolderGit2, Plus } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile';
|
||||
|
||||
export interface GitBrowseResult {
|
||||
files: string[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
interface GitComposeFilePickerProps {
|
||||
composePaths: string[];
|
||||
contextDir: string;
|
||||
onComposePathsChange: (paths: string[]) => void;
|
||||
onContextDirChange: (value: string) => void;
|
||||
/** Parent runs the correct browse endpoint (create vs edit) and returns the repo file list, or null on failure. */
|
||||
onBrowse: () => Promise<GitBrowseResult | null>;
|
||||
/** True when the repo URL + branch are filled, so a browse can succeed. */
|
||||
canBrowse: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const isComposeLike = (p: string) => /\.ya?ml$/i.test(p);
|
||||
|
||||
export function GitComposeFilePicker({
|
||||
composePaths,
|
||||
contextDir,
|
||||
onComposePathsChange,
|
||||
onContextDirChange,
|
||||
onBrowse,
|
||||
canBrowse,
|
||||
disabled = false,
|
||||
}: GitComposeFilePickerProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [manualPath, setManualPath] = useState('');
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
const [repoFiles, setRepoFiles] = useState<string[] | null>(null);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
|
||||
const addPath = (raw: string) => {
|
||||
const value = raw.trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
if (!value || composePaths.includes(value)) return;
|
||||
onComposePathsChange([...composePaths, value]);
|
||||
};
|
||||
|
||||
const removeAt = (index: number) => {
|
||||
onComposePathsChange(composePaths.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= composePaths.length || from === to) return;
|
||||
const next = [...composePaths];
|
||||
const [item] = next.splice(from, 1);
|
||||
next.splice(to, 0, item);
|
||||
onComposePathsChange(next);
|
||||
};
|
||||
|
||||
const runBrowse = async () => {
|
||||
setBrowsing(true);
|
||||
try {
|
||||
const result = await onBrowse();
|
||||
if (result) {
|
||||
// Compose-like files first so they are easy to pick out of a large repo.
|
||||
const sorted = [...result.files].sort((a, b) => {
|
||||
const ac = isComposeLike(a) ? 0 : 1;
|
||||
const bc = isComposeLike(b) ? 0 : 1;
|
||||
return ac - bc || a.localeCompare(b);
|
||||
});
|
||||
setRepoFiles(sorted);
|
||||
setTruncated(result.truncated);
|
||||
}
|
||||
} finally {
|
||||
setBrowsing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label>Compose files <span className="text-stat-subtitle font-normal">(merged in order)</span></Label>
|
||||
|
||||
{composePaths.length === 0 ? (
|
||||
<p className="text-[11px] text-stat-subtitle rounded-md border border-dashed border-glass-border px-3 py-2">
|
||||
No compose files selected. Browse the repository or add a path below.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{composePaths.map((p, index) => (
|
||||
<li
|
||||
key={`${p}-${index}`}
|
||||
draggable={!disabled && !isMobile}
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragOver={(e) => { if (dragIndex !== null) e.preventDefault(); }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
if (dragIndex !== null) move(dragIndex, index);
|
||||
setDragIndex(null);
|
||||
}}
|
||||
onDragEnd={() => setDragIndex(null)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-glass-border bg-card-bg/40 px-2 py-1.5',
|
||||
dragIndex === index && 'opacity-50',
|
||||
!disabled && !isMobile && 'cursor-grab',
|
||||
)}
|
||||
>
|
||||
{isMobile ? (
|
||||
<div className="flex flex-col -my-1">
|
||||
<button type="button" disabled={disabled || index === 0} onClick={() => move(index, index - 1)}
|
||||
className="text-stat-subtitle hover:text-foreground disabled:opacity-30" aria-label="Move up">
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled || index === composePaths.length - 1} onClick={() => move(index, index + 1)}
|
||||
className="text-stat-subtitle hover:text-foreground disabled:opacity-30" aria-label="Move down">
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<GripVertical className="w-3.5 h-3.5 text-stat-subtitle shrink-0" />
|
||||
)}
|
||||
<span className="font-mono text-xs truncate flex-1" title={p}>{p}</span>
|
||||
{index === 0 && <span className="text-[10px] text-stat-subtitle shrink-0">primary</span>}
|
||||
<button type="button" disabled={disabled} onClick={() => removeAt(index)}
|
||||
className="text-stat-subtitle hover:text-danger disabled:opacity-30 shrink-0" aria-label={`Remove ${p}`}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="path/to/compose.yaml"
|
||||
value={manualPath}
|
||||
onChange={(e) => setManualPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addPath(manualPath); setManualPath(''); }
|
||||
}}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled || !manualPath.trim()}
|
||||
onClick={() => { addPath(manualPath); setManualPath(''); }}>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled || !canBrowse || browsing}
|
||||
onClick={runBrowse}>
|
||||
<FolderGit2 className="w-3.5 h-3.5 mr-1" />
|
||||
{browsing ? 'Browsing...' : 'Browse'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{repoFiles && (
|
||||
<div className="rounded-md border border-glass-border">
|
||||
<ScrollArea className="max-h-48">
|
||||
<div className="p-2 space-y-1">
|
||||
{repoFiles.length === 0 && <p className="text-[11px] text-stat-subtitle px-1 py-2">No files found in the repository.</p>}
|
||||
{repoFiles.map((file) => {
|
||||
const selected = composePaths.includes(file);
|
||||
return (
|
||||
<label key={file} className="flex items-center gap-2 px-1 py-0.5 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(c) => {
|
||||
if (c === true) addPath(file);
|
||||
else onComposePathsChange(composePaths.filter(p => p !== file));
|
||||
}}
|
||||
/>
|
||||
<span className={cn('font-mono text-xs truncate', isComposeLike(file) ? 'text-foreground' : 'text-stat-subtitle')} title={file}>
|
||||
{file}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
{truncated && (
|
||||
<p className="text-[10px] text-stat-subtitle px-2 py-1 border-t border-glass-border">
|
||||
Repository has many files; only the first 2000 are listed. Add other paths manually.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="git-context-dir" className="text-xs">Project directory <span className="text-stat-subtitle font-normal">(optional)</span></Label>
|
||||
<Input
|
||||
id="git-context-dir"
|
||||
placeholder="e.g. deploy"
|
||||
value={contextDir}
|
||||
onChange={(e) => onContextDirChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-stat-subtitle">
|
||||
Sets <span className="font-mono">--project-directory</span> for relative paths. Leave blank to use the stack root.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export function GitSourceDiffDialog({
|
||||
<TabsList>
|
||||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="compose">
|
||||
<TabsTrigger value="compose">compose.yaml</TabsTrigger>
|
||||
<TabsTrigger value="compose">Compose</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="env">
|
||||
<TabsTrigger value="env">.env</TabsTrigger>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GitComposeFilePicker, type GitBrowseResult } from './GitComposeFilePicker';
|
||||
|
||||
export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy';
|
||||
|
||||
@@ -9,8 +10,8 @@ export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy';
|
||||
* Mirror of the backend's env-path default (see `/api/stacks/from-git` and
|
||||
* the git-source PUT handler): if the user ticks "Sync .env" without
|
||||
* specifying an explicit path, the service reads `<dirname>/.env`
|
||||
* alongside the compose file. Surfacing this in the form saves the user
|
||||
* a round-trip to figure out which directory the `.env` will come from.
|
||||
* alongside the primary compose file. Surfacing this in the form saves the
|
||||
* user a round-trip to figure out which directory the `.env` will come from.
|
||||
*/
|
||||
function computeDefaultEnvPath(composePath: string): string {
|
||||
const normalized = composePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
||||
@@ -22,7 +23,8 @@ function computeDefaultEnvPath(composePath: string): string {
|
||||
export interface GitSourceFieldsState {
|
||||
repoUrl: string;
|
||||
branch: string;
|
||||
composePath: string;
|
||||
composePaths: string[];
|
||||
contextDir: string;
|
||||
syncEnv: boolean;
|
||||
authType: 'none' | 'token';
|
||||
token: string;
|
||||
@@ -37,11 +39,14 @@ export interface GitSourceFieldsProps extends GitSourceFieldsState {
|
||||
variant: 'edit' | 'create';
|
||||
onRepoUrlChange: (value: string) => void;
|
||||
onBranchChange: (value: string) => void;
|
||||
onComposePathChange: (value: string) => void;
|
||||
onComposePathsChange: (value: string[]) => void;
|
||||
onContextDirChange: (value: string) => void;
|
||||
onSyncEnvChange: (value: boolean) => void;
|
||||
onAuthTypeChange: (value: 'none' | 'token') => void;
|
||||
onTokenChange: (value: string) => void;
|
||||
onApplyModeChange: (value: ApplyMode) => void;
|
||||
/** Runs the correct browse endpoint (create vs edit); returns the repo file list or null on failure. */
|
||||
onBrowse: () => Promise<GitBrowseResult | null>;
|
||||
}
|
||||
|
||||
const APPLY_MODE_COPY: Record<'edit' | 'create', Record<ApplyMode, { title: string; description: string }>> = {
|
||||
@@ -60,7 +65,8 @@ const APPLY_MODE_COPY: Record<'edit' | 'create', Record<ApplyMode, { title: stri
|
||||
export function GitSourceFields({
|
||||
repoUrl,
|
||||
branch,
|
||||
composePath,
|
||||
composePaths,
|
||||
contextDir,
|
||||
syncEnv,
|
||||
authType,
|
||||
token,
|
||||
@@ -70,13 +76,17 @@ export function GitSourceFields({
|
||||
variant,
|
||||
onRepoUrlChange,
|
||||
onBranchChange,
|
||||
onComposePathChange,
|
||||
onComposePathsChange,
|
||||
onContextDirChange,
|
||||
onSyncEnvChange,
|
||||
onAuthTypeChange,
|
||||
onTokenChange,
|
||||
onApplyModeChange,
|
||||
onBrowse,
|
||||
}: GitSourceFieldsProps) {
|
||||
const copy = APPLY_MODE_COPY[variant];
|
||||
const primaryComposePath = composePaths[0] ?? '';
|
||||
const canBrowse = !!repoUrl?.trim() && !!branch?.trim();
|
||||
|
||||
const radioOption = (mode: ApplyMode) => (
|
||||
<button
|
||||
@@ -119,31 +129,28 @@ export function GitSourceFields({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-branch">Branch</Label>
|
||||
<Input
|
||||
id="git-source-branch"
|
||||
placeholder="main"
|
||||
value={branch}
|
||||
onChange={(e) => onBranchChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-path">Compose file path</Label>
|
||||
<Input
|
||||
id="git-source-path"
|
||||
placeholder="compose.yaml"
|
||||
value={composePath}
|
||||
onChange={(e) => onComposePathChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="git-source-branch">Branch</Label>
|
||||
<Input
|
||||
id="git-source-branch"
|
||||
placeholder="main"
|
||||
value={branch}
|
||||
onChange={(e) => onBranchChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GitComposeFilePicker
|
||||
composePaths={composePaths}
|
||||
contextDir={contextDir}
|
||||
onComposePathsChange={onComposePathsChange}
|
||||
onContextDirChange={onContextDirChange}
|
||||
onBrowse={onBrowse}
|
||||
canBrowse={canBrowse}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
@@ -156,11 +163,11 @@ export function GitSourceFields({
|
||||
Also sync sibling <span className="font-mono">.env</span> file
|
||||
</Label>
|
||||
</div>
|
||||
{syncEnv && composePath.trim() !== '' && (
|
||||
{syncEnv && primaryComposePath.trim() !== '' && (
|
||||
<p className="text-[11px] text-stat-subtitle pl-6">
|
||||
Will read{' '}
|
||||
<span className="font-mono">
|
||||
{computeDefaultEnvPath(composePath)}
|
||||
{computeDefaultEnvPath(primaryComposePath)}
|
||||
</span>{' '}
|
||||
from the repository.
|
||||
</p>
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('GitSourcePanel load', () => {
|
||||
await screen.findByRole('button', { name: /^save$/i });
|
||||
expect(screen.queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /pull now/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /remove/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/repository url/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('GitSourcePanel load', () => {
|
||||
// A real source flips the primary action to Update and exposes Pull now / Remove.
|
||||
await screen.findByRole('button', { name: /update/i });
|
||||
expect(screen.getByRole('button', { name: /pull now/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByLabelText(/repository url/i)).toHaveValue('https://github.com/org/repo.git'),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useNodes } from '@/context/NodeContext';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { GitSourceDiffDialog, type PullResult } from './GitSourceDiffDialog';
|
||||
import { GitSourceFields, type ApplyMode } from './GitSourceFields';
|
||||
import type { GitBrowseResult } from './GitComposeFilePicker';
|
||||
|
||||
export interface GitSource {
|
||||
id: number;
|
||||
@@ -17,6 +18,8 @@ export interface GitSource {
|
||||
repo_url: string;
|
||||
branch: string;
|
||||
compose_path: string;
|
||||
compose_paths: string[];
|
||||
context_dir: string | null;
|
||||
sync_env: boolean;
|
||||
env_path: string | null;
|
||||
auth_type: 'none' | 'token';
|
||||
@@ -64,7 +67,8 @@ export function GitSourcePanel({
|
||||
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
const [branch, setBranch] = useState('main');
|
||||
const [composePath, setComposePath] = useState('compose.yaml');
|
||||
const [composePaths, setComposePaths] = useState<string[]>(['compose.yaml']);
|
||||
const [contextDir, setContextDir] = useState('');
|
||||
const [syncEnv, setSyncEnv] = useState(false);
|
||||
const [authType, setAuthType] = useState<'none' | 'token'>('none');
|
||||
const [token, setToken] = useState('');
|
||||
@@ -82,7 +86,8 @@ export function GitSourcePanel({
|
||||
setSource(null);
|
||||
setRepoUrl('');
|
||||
setBranch('main');
|
||||
setComposePath('compose.yaml');
|
||||
setComposePaths(['compose.yaml']);
|
||||
setContextDir('');
|
||||
setSyncEnv(false);
|
||||
setAuthType('none');
|
||||
setToken('');
|
||||
@@ -102,7 +107,8 @@ export function GitSourcePanel({
|
||||
setSource(data);
|
||||
setRepoUrl(data.repo_url);
|
||||
setBranch(data.branch);
|
||||
setComposePath(data.compose_path);
|
||||
setComposePaths(data.compose_paths?.length ? data.compose_paths : [data.compose_path]);
|
||||
setContextDir(data.context_dir ?? '');
|
||||
setSyncEnv(data.sync_env);
|
||||
setAuthType(data.auth_type);
|
||||
setToken('');
|
||||
@@ -131,8 +137,8 @@ export function GitSourcePanel({
|
||||
}, [open, load]);
|
||||
|
||||
const save = async () => {
|
||||
if (!repoUrl.trim() || !branch.trim() || !composePath.trim()) {
|
||||
toast.error('Repository URL, branch, and compose path are required.');
|
||||
if (!repoUrl.trim() || !branch.trim() || composePaths.length === 0) {
|
||||
toast.error('Repository URL, branch, and at least one compose file are required.');
|
||||
return;
|
||||
}
|
||||
if (!/^https:\/\//i.test(repoUrl.trim())) {
|
||||
@@ -147,7 +153,8 @@ export function GitSourcePanel({
|
||||
const body: Record<string, unknown> = {
|
||||
repo_url: repoUrl.trim(),
|
||||
branch: branch.trim(),
|
||||
compose_path: composePath.trim(),
|
||||
compose_paths: composePaths,
|
||||
context_dir: contextDir.trim() || null,
|
||||
sync_env: syncEnv,
|
||||
auth_type: authType,
|
||||
auto_apply_on_webhook: autoApply,
|
||||
@@ -179,6 +186,35 @@ export function GitSourcePanel({
|
||||
}
|
||||
};
|
||||
|
||||
const browseRepo = async (): Promise<GitBrowseResult | null> => {
|
||||
if (!repoUrl.trim() || !branch.trim()) {
|
||||
toast.error('Enter a repository URL and branch first.');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
repo_url: repoUrl.trim(),
|
||||
branch: branch.trim(),
|
||||
auth_type: authType,
|
||||
};
|
||||
if (authType === 'token' && token !== '') body.token = token;
|
||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/browse`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return { files: data.files ?? [], truncated: data.truncated ?? false };
|
||||
}
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || 'Failed to browse repository.');
|
||||
return null;
|
||||
} catch (e) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!source) return;
|
||||
setRemoveConfirmOpen(false);
|
||||
@@ -343,7 +379,8 @@ export function GitSourcePanel({
|
||||
disabled={!canEdit || saving}
|
||||
repoUrl={repoUrl}
|
||||
branch={branch}
|
||||
composePath={composePath}
|
||||
composePaths={composePaths}
|
||||
contextDir={contextDir}
|
||||
syncEnv={syncEnv}
|
||||
authType={authType}
|
||||
token={token}
|
||||
@@ -351,11 +388,13 @@ export function GitSourcePanel({
|
||||
applyMode={applyMode}
|
||||
onRepoUrlChange={setRepoUrl}
|
||||
onBranchChange={setBranch}
|
||||
onComposePathChange={setComposePath}
|
||||
onComposePathsChange={setComposePaths}
|
||||
onContextDirChange={setContextDir}
|
||||
onSyncEnvChange={setSyncEnv}
|
||||
onAuthTypeChange={setAuthType}
|
||||
onTokenChange={setToken}
|
||||
onApplyModeChange={setApplyModeOverride}
|
||||
onBrowse={browseRepo}
|
||||
/>
|
||||
|
||||
{source && (
|
||||
|
||||
Reference in New Issue
Block a user