git sync UI improvements (#6303)

* ui improvements round 1

* modal cleanup

* init

* UI refactor

* UI cleanup + refactor

* legacy cleanup

* success model -> github actions, non-ee warnings

* sqlx

* npm check

* ee warning everywhere

* last comments

* formatting

* no hardcoded theme

* claude review improvemenets
This commit is contained in:
Alexander Petric
2025-07-30 21:36:32 +00:00
committed by GitHub
parent b04a203e9a
commit dcc20c4fc1
26 changed files with 3063 additions and 2282 deletions
@@ -15,7 +15,7 @@
]
},
"nullable": [
true
null
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
false,
true
true,
false
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
@@ -41,11 +41,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true
]
},
+80 -41
View File
@@ -2455,6 +2455,65 @@ paths:
application/json:
schema: {}
/w/{workspace}/workspaces/edit_git_sync_repository:
post:
summary: add or update individual git sync repository
operationId: editGitSyncRepository
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Git sync repository settings to add or update
required: true
content:
application/json:
schema:
type: object
properties:
git_repo_resource_path:
type: string
description: The resource path of the git repository to update
repository:
$ref: "#/components/schemas/GitRepositorySettings"
required:
- git_repo_resource_path
- repository
responses:
"200":
description: status
content:
application/json:
schema: {}
/w/{workspace}/workspaces/delete_git_sync_repository:
delete:
summary: delete individual git sync repository
operationId: deleteGitSyncRepository
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: Git sync repository to delete
required: true
content:
application/json:
schema:
type: object
properties:
git_repo_resource_path:
type: string
description: The resource path of the git repository to delete
required:
- git_repo_resource_path
responses:
"200":
description: status
content:
application/json:
schema: {}
/w/{workspace}/workspaces/edit_deploy_ui_config:
post:
summary: edit workspace deploy ui settings
@@ -13649,6 +13708,24 @@ components:
customai,
]
GitSyncObjectType:
type: string
enum:
- script
- flow
- app
- folder
- resource
- variable
- secret
- resourcetype
- schedule
- user
- group
- trigger
- settings
- key
AIProviderModel:
type: object
properties:
@@ -17134,15 +17211,7 @@ components:
include_type:
type: array
items:
type: string
enum:
- script
- flow
- app
- resource
- variable
- secret
- trigger
$ref: "#/components/schemas/GitSyncObjectType"
WorkspaceDefaultScripts:
type: object
@@ -17182,22 +17251,7 @@ components:
include_type:
type: array
items:
type: string
enum:
- script
- flow
- app
- folder
- resource
- variable
- secret
- resourcetype
- schedule
- user
- group
- trigger
- settings
- key
$ref: "#/components/schemas/GitSyncObjectType"
exclude_path:
type: array
items:
@@ -17209,22 +17263,7 @@ components:
exclude_types_override:
type: array
items:
type: string
enum:
- script
- flow
- app
- folder
- resource
- variable
- secret
- resourcetype
- schedule
- user
- group
- trigger
- settings
- key
$ref: "#/components/schemas/GitSyncObjectType"
required:
- script_path
- git_repo_resource_path
+293 -1
View File
@@ -47,6 +47,8 @@ use windmill_common::{
oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH,
utils::{paginate, rd_string, require_admin, Pagination},
};
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::GitRepositorySettings;
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
#[cfg(feature = "enterprise")]
@@ -113,6 +115,8 @@ pub fn workspaced_service() -> Router {
post(edit_large_file_storage_config),
)
.route("/edit_git_sync_config", post(edit_git_sync_config))
.route("/edit_git_sync_repository", post(edit_git_sync_repository))
.route("/delete_git_sync_repository", delete(delete_git_sync_repository))
.route("/edit_deploy_ui_config", post(edit_deploy_ui_config))
.route("/edit_default_app", post(edit_default_app))
.route("/default_app", get(get_default_app))
@@ -869,6 +873,76 @@ pub struct EditGitSyncConfig {
pub git_sync_settings: Option<WorkspaceGitSyncSettings>,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize, Debug)]
pub struct EditGitSyncRepository {
pub git_repo_resource_path: String,
pub repository: GitRepositorySettings,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize, Debug)]
pub struct DeleteGitSyncRepositoryRequest {
pub git_repo_resource_path: String,
}
#[cfg(feature = "enterprise")]
fn validate_git_repo_resource_path(path: &str) -> Result<()> {
// Resource paths should follow the pattern: $res:f/<folder>/<name> or $res:u/<username>/<name>
if path.is_empty() {
return Err(Error::BadRequest("Resource path cannot be empty".to_string()));
}
// Must start with $res: prefix
if !path.starts_with("$res:") {
return Err(Error::BadRequest("Resource path must start with '$res:'".to_string()));
}
// Extract the actual path after $res:
let actual_path = &path[5..]; // Remove "$res:" prefix
// Basic validation: must start with f/ or u/ and contain at least one slash
if !actual_path.starts_with("f/") && !actual_path.starts_with("u/") {
return Err(Error::BadRequest("Resource path must start with '$res:f/' or '$res:u/'".to_string()));
}
// Must have at least 3 parts (type, folder/user, name)
let parts: Vec<&str> = actual_path.split('/').collect();
if parts.len() < 3 || parts.iter().any(|part| part.is_empty()) {
return Err(Error::BadRequest("Invalid resource path format".to_string()));
}
// Resource name validation (last part)
let resource_name = parts.last().unwrap();
if !resource_name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') {
return Err(Error::BadRequest("Resource name can only contain alphanumeric characters, underscores, and hyphens".to_string()));
}
Ok(())
}
#[cfg(feature = "enterprise")]
fn cleanup_legacy_git_sync_settings_in_memory(
git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings,
workspace_id: &str,
) {
// Check if all repositories are in new format (have settings field)
let all_repos_migrated = git_sync_settings.repositories.iter()
.all(|repo| repo.settings.is_some());
// If all repos are migrated and we still have legacy workspace-level settings
if all_repos_migrated && (git_sync_settings.include_path.is_some() || git_sync_settings.include_type.is_some()) {
tracing::info!(
workspace_id = workspace_id,
"All git sync repositories migrated to new format, cleaning up legacy workspace-level settings"
);
// Remove workspace-level legacy fields
git_sync_settings.include_path = None;
git_sync_settings.include_type = None;
}
}
#[cfg(not(feature = "enterprise"))]
async fn edit_git_sync_config(
_authed: ApiAuthed,
@@ -905,7 +979,10 @@ async fn edit_git_sync_config(
)
.await?;
if let Some(git_sync_settings) = new_config.git_sync_settings {
if let Some(mut git_sync_settings) = new_config.git_sync_settings {
// Clean up legacy workspace-level settings if all repos are migrated
cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id);
let serialized_config = serde_json::to_value::<WorkspaceGitSyncSettings>(git_sync_settings)
.map_err(|err| Error::internal_err(err.to_string()))?;
@@ -924,6 +1001,7 @@ async fn edit_git_sync_config(
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
// Trigger git sync for git sync settings changes
@@ -941,6 +1019,220 @@ async fn edit_git_sync_config(
Ok(format!("Edit git sync config for workspace {}", &w_id))
}
#[cfg(not(feature = "enterprise"))]
async fn edit_git_sync_repository(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_new_config): Json<serde_json::Value>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
}
#[cfg(feature = "enterprise")]
async fn edit_git_sync_repository(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
Json(new_config): Json<EditGitSyncRepository>,
) -> Result<String> {
require_admin(is_admin, &username)?;
// Validate the resource path format
validate_git_repo_resource_path(&new_config.git_repo_resource_path)?;
let mut tx = db.begin().await?;
// First, get the current git sync settings
let current_settings = sqlx::query!(
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_optional(&mut *tx)
.await?;
let mut git_sync_settings = if let Some(row) = current_settings {
if let Some(git_sync) = row.git_sync {
serde_json::from_value::<WorkspaceGitSyncSettings>(git_sync)
.map_err(|err| Error::internal_err(err.to_string()))?
} else {
WorkspaceGitSyncSettings::default()
}
} else {
WorkspaceGitSyncSettings::default()
};
// Audit log before we move the repository
audit_log(
&mut *tx,
&authed,
"workspaces.edit_git_sync_repository",
ActionKind::Update,
&w_id,
Some(&authed.email),
Some([("repository_path", new_config.git_repo_resource_path.as_str()), ("repository_data", &format!("{:?}", new_config.repository))].into()),
)
.await?;
// Check if repository exists before modifying
let repo_exists = git_sync_settings.repositories.iter()
.any(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path);
// Find and update the specific repository, or add it if it doesn't exist
let repo_found = git_sync_settings.repositories.iter_mut()
.find(|repo| repo.git_repo_resource_path == new_config.git_repo_resource_path);
if let Some(existing_repo) = repo_found {
// Update existing repository
*existing_repo = new_config.repository;
} else {
// Repository doesn't exist, add it as a new repository
git_sync_settings.repositories.push(new_config.repository);
}
// Clean up legacy workspace-level settings if all repos are migrated
cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id);
// Save the updated configuration
let serialized_config = serde_json::to_value::<WorkspaceGitSyncSettings>(git_sync_settings)
.map_err(|err| Error::internal_err(err.to_string()))?;
sqlx::query!(
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
serialized_config,
&w_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
// Trigger git sync for individual repository update/add
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() },
Some(format!("Git sync repository '{}' {}",
new_config.git_repo_resource_path,
if repo_exists { "updated" } else { "added" }
)),
false,
)
.await?;
Ok(format!("{} git sync repository '{}' for workspace {}",
if repo_exists { "Updated" } else { "Added" },
new_config.git_repo_resource_path,
&w_id
))
}
#[cfg(not(feature = "enterprise"))]
async fn delete_git_sync_repository(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_request): Json<serde_json::Value>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
}
#[cfg(feature = "enterprise")]
async fn delete_git_sync_repository(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
ApiAuthed { is_admin, username, .. }: ApiAuthed,
Json(request): Json<DeleteGitSyncRepositoryRequest>,
) -> Result<String> {
require_admin(is_admin, &username)?;
// Validate the resource path format
validate_git_repo_resource_path(&request.git_repo_resource_path)?;
let mut tx = db.begin().await?;
// First, get the current git sync settings
let current_settings = sqlx::query!(
"SELECT git_sync FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_optional(&mut *tx)
.await?;
let mut git_sync_settings = if let Some(row) = current_settings {
if let Some(git_sync) = row.git_sync {
serde_json::from_value::<WorkspaceGitSyncSettings>(git_sync)
.map_err(|err| Error::internal_err(err.to_string()))?
} else {
WorkspaceGitSyncSettings::default()
}
} else {
WorkspaceGitSyncSettings::default()
};
// Check if repository exists and remove it
let original_count = git_sync_settings.repositories.len();
git_sync_settings.repositories.retain(|repo| repo.git_repo_resource_path != request.git_repo_resource_path);
if git_sync_settings.repositories.len() == original_count {
return Err(Error::BadRequest(format!(
"Repository with path '{}' not found in git sync configuration",
request.git_repo_resource_path
)));
}
// Audit log
audit_log(
&mut *tx,
&authed,
"workspaces.delete_git_sync_repository",
ActionKind::Delete,
&w_id,
Some(&authed.email),
Some([("repository_path", request.git_repo_resource_path.as_str())].into()),
)
.await?;
// Clean up legacy workspace-level settings if all repos are migrated
cleanup_legacy_git_sync_settings_in_memory(&mut git_sync_settings, &w_id);
// Save the updated configuration
let serialized_config = serde_json::to_value::<WorkspaceGitSyncSettings>(git_sync_settings)
.map_err(|err| Error::internal_err(err.to_string()))?;
sqlx::query!(
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
serialized_config,
&w_id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
// Trigger git sync for repository deletion
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
windmill_git_sync::DeployedObject::Settings { setting_type: "git_sync".to_string() },
Some(format!("Git sync repository '{}' deleted", request.git_repo_resource_path)),
false,
)
.await?;
Ok(format!("Deleted git sync repository '{}' from workspace {}", request.git_repo_resource_path, &w_id))
}
#[derive(Debug, Deserialize)]
struct EditDeployUIConfig {
#[cfg(feature = "enterprise")]
@@ -583,7 +583,7 @@
<div class="mt-6"></div>
{#if !$enterpriseLicense}
<Alert type="error" title="Enterprise license required"
<Alert type="warning" title="Enterprise license required"
>Deploy to staging/prod from the web UI is only available with an enterprise license</Alert
>
{:else if notSet == true}
@@ -1,35 +1,26 @@
<script lang="ts">
type DiffResult = {
added: string[]
deleted: string[]
modified: string[]
repoWmillYaml?: string
yamlModified?: boolean
}
import type { SyncResponse } from '$lib/git-sync'
let { previewResult } = $props<{
previewResult: DiffResult | undefined
previewResult: SyncResponse | undefined
}>()
let added = $derived(previewResult?.changes?.filter(c => c.type === 'added').map(c => c.path) || [])
let deleted = $derived(previewResult?.changes?.filter(c => c.type === 'deleted').map(c => c.path) || [])
let edited = $derived(previewResult?.changes?.filter(c => c.type === 'edited').map(c => c.path) || [])
</script>
<div class="border rounded p-2 text-xs max-h-40 overflow-y-auto bg-surface-secondary">
<div class="font-semibold text-[11px] mb-1 text-tertiary">Preview of changes:</div>
{#if !previewResult?.added?.length && !previewResult?.deleted?.length && !previewResult?.modified?.length && !previewResult?.yamlModified}
{#if !added.length && !deleted.length && !edited.length}
<div class="mt-2 text-tertiary">No changes found! The workspace is up to date.</div>
{:else}
{#if previewResult?.yamlModified}
<div class="mt-2">
<div class="text-yellow-600">Modified:</div>
<ul class="list-disc list-inside">
<li>wmill.yaml (Git sync settings)</li>
</ul>
</div>
{/if}
{#if previewResult?.added?.length}
{#if added.length}
<div class="mt-2">
<div class="text-green-600">Added:</div>
<ul class="list-disc list-inside">
{#each previewResult.added as file}
{#each added as file}
<li>
{file}{!file.includes('.') ? ' (dir)' : ''}
</li>
@@ -37,11 +28,11 @@
</ul>
</div>
{/if}
{#if previewResult?.deleted?.length}
{#if deleted.length}
<div class="mt-2">
<div class="text-red-600">Deleted:</div>
<ul class="list-disc list-inside">
{#each previewResult.deleted as file}
{#each deleted as file}
<li>
{file}{!file.includes('.') ? ' (dir)' : ''}
</li>
@@ -49,11 +40,11 @@
</ul>
</div>
{/if}
{#if previewResult?.modified?.length}
{#if edited.length}
<div class="mt-2">
<div class="text-yellow-600">Modified:</div>
<div class="text-yellow-600">Edited:</div>
<ul class="list-disc list-inside">
{#each previewResult.modified as file}
{#each edited as file}
<li>
{file}{!file.includes('.') ? ' (dir)' : ''}
</li>
@@ -1,454 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { Alert } from '$lib/components/common'
import {
Loader2,
Eye,
Save,
CheckCircle2,
XCircle,
UploadCloud,
AlertTriangle,
Terminal,
ChevronDown,
ChevronUp
} from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { JobService } from '$lib/gen'
import { tryEvery } from '$lib/utils'
// Types for git sync result
interface GitSyncChange {
type: 'added' | 'deleted' | 'modified'
path: string
}
interface GitSyncResult {
changes: GitSyncChange[]
}
import GitDiffPreview from './GitDiffPreview.svelte'
import { page } from '$app/stores'
let { gitRepoResourcePath, branchName, uiState } = $props<{
gitRepoResourcePath: string
branchName?: string
uiState: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: string[]
}
}>()
let _branchName = $state(branchName ?? '')
let previewResult = $state<
| {
added: string[]
deleted: string[]
modified: string[]
}
| undefined
>(undefined)
let isPreviewLoading = $state(false)
let isInitializing = $state(false)
let initResult = $state<{ success: boolean; message: string | undefined } | null>(null)
let initGitRepoPopover = $state<{ open: () => void; close: () => void } | null>(null)
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let pushJobId = $state<string | null>(null)
let pushJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isCliInfoExpanded = $state(false)
async function previewChanges() {
console.log('Previewing changes for repo:', gitRepoResourcePath)
isPreviewLoading = true
previewJobId = null
previewJobStatus = undefined
try {
const workspace = $workspaceStore
if (!workspace) {
previewResult = undefined
isPreviewLoading = false
return
}
// Pass UI state directly as JSON to CLI
const payloadObj = {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
branch_to_push: _branchName,
dry_run: true,
settings_json: JSON.stringify(uiState)
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payloadObj,
skipPreprocessor: true
})
previewJobId = jobId
previewJobStatus = 'running'
// Wait for job completion (polling)
let jobSuccess = false
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({
workspace,
id: jobId
})
jobSuccess = !!testResult.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: {
reason: 'Preview job timed out after 15s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 15000
})
if (jobSuccess) {
const result = await JobService.getCompletedJobResult({
workspace,
id: jobId
})
console.log('Preview result:', result)
// Convert new CLI format to expected format
const added: string[] = []
const deleted: string[] = []
const modified: string[] = []
if (
result &&
(result as GitSyncResult).changes &&
Array.isArray((result as GitSyncResult).changes)
) {
for (const change of (result as GitSyncResult).changes) {
if (change.type === 'added') {
added.push(change.path)
} else if (change.type === 'deleted') {
deleted.push(change.path)
} else if (change.type === 'modified') {
modified.push(change.path)
}
}
}
previewResult = { added, deleted, modified }
previewJobStatus = 'success'
} else {
previewResult = undefined
previewJobStatus = 'failure'
}
} catch (error) {
console.error('Failed to preview changes:', error)
previewResult = undefined
previewJobStatus = 'failure'
} finally {
isPreviewLoading = false
}
}
async function initializeRepo() {
const workspace = $workspaceStore
if (!workspace) return
console.log('Initializing repo:', gitRepoResourcePath, 'in workspace:', workspace)
isInitializing = true
initResult = null
pushJobId = null
pushJobStatus = undefined
try {
// Pass UI state directly as JSON to CLI
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
branch_to_push: _branchName,
settings_json: JSON.stringify(uiState)
},
skipPreprocessor: true
})
pushJobId = jobId
pushJobStatus = 'running'
let jobSuccess = false
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({
workspace,
id: jobId
})
jobSuccess = !!testResult.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: {
reason: 'Push job timed out after 5s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 10000
})
pushJobStatus = jobSuccess ? 'success' : 'failure'
initResult = {
success: jobSuccess,
message: jobSuccess ? undefined : 'Failed to initialize repository.'
}
// Reset popover state after successful push
if (jobSuccess) {
setTimeout(() => {
previewResult = undefined
pushJobId = null
pushJobStatus = undefined
initResult = null
initGitRepoPopover?.close()
}, 1500) // Small delay to show success state
}
} catch (error) {
console.error('Failed to initialize repo:', error)
pushJobStatus = 'failure'
initResult = { success: false, message: 'Failed to initialize repository.' }
} finally {
isInitializing = false
}
}
</script>
<Popover
bind:this={initGitRepoPopover}
floatingConfig={{
placement: 'top-start',
strategy: 'fixed',
flip: false,
shift: true
}}
contentClasses="p-4 w-1/3"
>
<svelte:fragment slot="trigger">
<Button
color="dark"
size="sm"
nonCaptureEvent
onclick={initGitRepoPopover?.open}
startIcon={{ icon: UploadCloud }}
>
Push workspace to Git repo
</Button>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-lg font-semibold">Push workspace to Git repository</h3>
<div class="prose max-w-none text-2xs text-tertiary">
This action will push all workspace objects that match your current filter settings to the
selected branch in your Git repository. <span
class="text-orange-600 flex items-center gap-1"
><AlertTriangle size={14} /> Any existing content in the branch will be replaced with the
filtered workspace content.</span
>
<!-- Collapsible CLI Info Section -->
<div class="mt-2 border rounded-md">
<button
class="w-full flex items-center justify-between p-1.5 bg-surface-secondary hover:bg-surface-hover"
onclick={() => (isCliInfoExpanded = !isCliInfoExpanded)}
>
<span class="font-medium flex items-center gap-2">
<Terminal size={14} />
Windmill CLI to pull from Windmill and push to git
</span>
{#if isCliInfoExpanded}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if isCliInfoExpanded}
<div class="p-1 bg-surface-tertiary">
<div class="text-2xs mb-2">
Not familiar with Windmill CLI? <a
href="https://www.windmill.dev/docs/advanced/cli/sync"
class="text-blue-500 hover:text-blue-600 underline"
target="_blank"
rel="noopener noreferrer">Check out the docs</a
>
</div>
<div class="font-mono text-2xs">
<pre class="overflow-auto max-h-60"
><code
>npm install -g windmill-cli
wmill workspace add {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
wmill init
# adjust wmill.yaml file configuraton as needed
wmill sync pull
git add -A
git commit -m 'Initial commit'
git push</code
></pre
>
</div>
</div>
{/if}
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<label for="branch-name" class="text-sm font-medium">Push to new branch (optional)</label>
<div class="prose max-w-none text-2xs text-tertiary">
Enter a new branch name to push to (e.g so you can merge back into main with a pull
request). If left blank, the default branch from the git repository resource will be used.
</div>
<div class="flex flex-col w-1/4">
<input
id="branch-name"
type="text"
bind:value={_branchName}
class="border rounded px-2 py-1"
/>
</div>
</div>
{#if previewResult}
<GitDiffPreview {previewResult} />
{/if}
{#if previewJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if previewJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if previewJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if previewJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Preview job:
<a
target="_blank"
class="underline"
href={`/run/${previewJobId}?workspace=${$workspaceStore}`}
>
{previewJobId}
</a>
</div>
{/if}
{#if pushJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if pushJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if pushJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if pushJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Push job:
<a
target="_blank"
class="underline"
href={`/run/${pushJobId}?workspace=${$workspaceStore}`}
>
{pushJobId}
</a>
</div>
{/if}
<!-- Action row: Cancel on left, Preview/Confirm on right -->
<div class="flex justify-between items-center mt-4">
<Button
color="light"
size="xs"
on:click={() => {
previewResult = undefined
previewJobId = null
previewJobStatus = undefined
pushJobId = null
pushJobStatus = undefined
initResult = null
close()
}}
disabled={isPreviewLoading || isInitializing}
>
Cancel
</Button>
<div class="flex gap-2">
{#if !previewResult}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isInitializing}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
Preview
</Button>
{:else}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isInitializing}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
title="Preview changes again"
>
Preview
</Button>
{#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length}
<Button
color="red"
size="xs"
on:click={initializeRepo}
disabled={isPreviewLoading || isInitializing}
startIcon={{ icon: Save }}
title="Initialize Git Repo"
>
Push
</Button>
{/if}
{/if}
</div>
</div>
{#if initResult?.message}
<div class="mt-2">
<Alert
type={initResult.success ? 'success' : 'error'}
title={initResult.success ? 'Success' : 'Error'}
size="xs"
>
{initResult.message}
</Alert>
</div>
{/if}
</div>
</svelte:fragment>
</Popover>
@@ -71,7 +71,7 @@
<div class="relative max-h-100">
{#if !$enterpriseLicense}
<Alert type="error" title="Enterprise Edition only feature">
<Alert type="warning" title="Enterprise Edition only feature">
Job metrics are only available on Windmill Enterprise Edition.
</Alert>
{:else if (jobMemoryStats?.length ?? 0) === 0}
@@ -1,418 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import {
Loader2,
Eye,
Save,
CheckCircle2,
XCircle,
DownloadCloud,
AlertTriangle,
Terminal,
ChevronDown,
ChevronUp
} from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { JobService } from '$lib/gen'
import { tryEvery } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import GitDiffPreview from './GitDiffPreview.svelte'
import { page } from '$app/stores'
// Types for git sync result
interface GitSyncChange {
type: 'added' | 'deleted' | 'modified'
path: string
}
interface GitSyncResult {
changes: GitSyncChange[]
}
let { gitRepoResourcePath, uiState, onFilterUpdate } = $props<{
gitRepoResourcePath: string
uiState: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: string[]
}
onFilterUpdate: (filters: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: string[]
}) => void
}>()
type PreviewResult = {
added: string[]
deleted: string[]
modified: string[]
}
let previewResult = $state<PreviewResult | undefined>(undefined)
let isPreviewLoading = $state(false)
let isPulling = $state(false)
let pullGitRepoPopover = $state<{ open: () => void; close: () => void } | null>(null)
let jobStatus = $state<{
id: string | null
status: 'running' | 'success' | 'failure' | undefined
error?: string
type: 'preview' | 'pull'
}>({
id: null,
status: undefined,
type: 'preview'
})
let isCliInfoExpanded = $state(false)
async function handleJobCompletion(jobId: string, workspace: string): Promise<boolean> {
let success = false
await tryEvery({
tryCode: async () => {
const result = await JobService.getCompletedJob({
workspace,
id: jobId
})
success = !!result.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: {
reason: 'Job timed out after 5s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 10000
})
return success
}
async function previewChanges() {
const workspace = $workspaceStore
if (!workspace) return
console.log('Previewing changes for repo:', gitRepoResourcePath)
isPreviewLoading = true
jobStatus = { id: null, status: undefined, type: 'preview' }
try {
// Always use the simplified JSON approach
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: true,
pull: true,
only_wmill_yaml: false,
settings_json: JSON.stringify(uiState)
},
skipPreprocessor: true
})
jobStatus = { id: jobId, status: 'running', type: 'preview' }
const success = await handleJobCompletion(jobId, workspace)
if (success) {
const rawResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
console.log('Preview result:', rawResult)
// Convert new CLI format to expected format
const added: string[] = []
const deleted: string[] = []
const modified: string[] = []
if (
rawResult &&
(rawResult as GitSyncResult).changes &&
Array.isArray((rawResult as GitSyncResult).changes)
) {
for (const change of (rawResult as GitSyncResult).changes) {
if (change.type === 'added') {
added.push(change.path)
} else if (change.type === 'deleted') {
deleted.push(change.path)
} else if (change.type === 'modified') {
modified.push(change.path)
}
}
}
// For full sync mode, just use the CLI results directly
// The CLI already handles wmill.yaml changes with --include-wmill-yaml flag
previewResult = { added, deleted, modified }
jobStatus.status = 'success'
} else {
previewResult = undefined
jobStatus.status = 'failure'
}
} catch (error) {
console.error('Failed to preview changes:', error)
previewResult = undefined
jobStatus = {
...jobStatus,
status: 'failure',
error: error instanceof Error ? error.message : String(error)
}
} finally {
isPreviewLoading = false
}
}
async function pullFromRepo() {
const workspace = $workspaceStore
if (!workspace) return
console.log('Pulling from repo:', gitRepoResourcePath)
isPulling = true
jobStatus = { id: null, status: undefined, type: 'pull' }
try {
// Use init git repo script with dry_run: false (actual pull operation)
// The script will read wmill.yaml directly from the cloned repo, no need to pass settings
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: false,
branch_to_push: '',
only_wmill_yaml: false,
pull: true,
settings_json: undefined // Let script use wmill.yaml from repo
},
skipPreprocessor: true
})
jobStatus = { id: jobId, status: 'running', type: 'pull' }
const success = await handleJobCompletion(jobId, workspace)
jobStatus.status = success ? 'success' : 'failure'
if (success) {
// Get the result which should contain the local git repo settings as JSON
const result = (await JobService.getCompletedJobResult({ workspace, id: jobId })) as any
console.log('Pull result:', result)
// Apply the settings from the sync operation result to the UI
if (result?.settings_json) {
// Directly update the UI state with the JSON result - no YAML conversion needed!
const settingsJson = result.settings_json as {
include_path: string[]
exclude_path?: string[]
extra_include_path?: string[]
include_type: string[]
}
onFilterUpdate({
include_path: settingsJson.include_path || ['f/**'],
exclude_path: settingsJson.exclude_path || [],
extra_include_path: settingsJson.extra_include_path || [],
include_type: settingsJson.include_type || ['script', 'flow', 'app', 'folder']
})
sendUserToast('Successfully pulled workspace content from repository')
// Reset popover state after successful pull
previewResult = undefined
jobStatus = { id: null, status: undefined, type: 'preview' }
pullGitRepoPopover?.close()
} else {
console.warn('No settings_json returned from pull operation')
sendUserToast('Pull completed but could not update filter settings', true)
}
}
} catch (error) {
console.error('Failed to pull from repo:', error)
jobStatus = {
...jobStatus,
status: 'failure',
error: error instanceof Error ? error.message : String(error)
}
} finally {
isPulling = false
}
}
</script>
<Popover
bind:this={pullGitRepoPopover}
floatingConfig={{
placement: 'top-start',
strategy: 'fixed',
flip: false,
shift: true
}}
contentClasses="p-4 w-1/3"
>
<svelte:fragment slot="trigger">
<Button
color="dark"
size="sm"
nonCaptureEvent
onclick={pullGitRepoPopover?.open}
startIcon={{ icon: DownloadCloud }}
>
Pull workspace from Git repo
</Button>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-lg font-semibold">Pull workspace from Git repository</h3>
<div class="prose max-w-none text-2xs text-tertiary">
This action will pull all workspace objects from your Git repository according to the
filters set in the Git repository wmill.yaml file and apply those filter settings to the
workspace.
<span class="text-orange-600 flex items-center gap-1">
<AlertTriangle size={14} /> This will overwrite your current workspace content and Git sync
filter settings with the content from the Git repository.
</span>
<!-- Collapsible CLI Info Section -->
<div class="mt-2 border rounded-md">
<button
class="w-full flex items-center justify-between p-1.5 bg-surface-secondary hover:bg-surface-hover"
onclick={() => (isCliInfoExpanded = !isCliInfoExpanded)}
>
<span class="font-medium flex items-center gap-2">
<Terminal size={14} />
Windmill CLI to push local files to Windmill
</span>
{#if isCliInfoExpanded}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if isCliInfoExpanded}
<div class="p-1 bg-surface-tertiary">
<div class="text-2xs mb-2">
Not familiar with Windmill CLI? <a
href="https://www.windmill.dev/docs/advanced/cli/sync"
class="text-blue-500 hover:text-blue-600 underline"
target="_blank"
rel="noopener noreferrer">Check out the docs</a
>
</div>
<div class="font-mono text-2xs">
<pre class="overflow-auto max-h-60"
><code
>npm install -g windmill-cli
# Clone your git repository
git clone $REPO_URL
cd $REPO_NAME
# Configure Windmill CLI
wmill workspace add {$workspaceStore} {$workspaceStore} {`${$page.url.protocol}//${$page.url.hostname}/`}
# Push the content to Windmill
wmill sync push --yes
# Optional: add --skip-secrets --skip-variables --skip-resources flags as needed</code
></pre
>
</div>
</div>
{/if}
</div>
</div>
</div>
{#if previewResult}
<GitDiffPreview {previewResult} />
{/if}
{#if jobStatus.id}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if jobStatus.status === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if jobStatus.status === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if jobStatus.status === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
{jobStatus.type === 'preview' ? 'Preview' : 'Pull'} job:
<a
target="_blank"
class="underline"
href={`/run/${jobStatus.id}?workspace=${$workspaceStore}`}
>
{jobStatus.id}
</a>
</div>
{#if jobStatus.error}
<div class="text-xs text-red-600">{jobStatus.error}</div>
{/if}
{/if}
<div class="flex justify-between items-center mt-4">
<Button
color="light"
size="xs"
on:click={() => {
previewResult = undefined
jobStatus = { id: null, status: undefined, type: 'preview' }
close()
}}
disabled={isPreviewLoading || isPulling}
>
Cancel
</Button>
<div class="flex gap-2">
{#if !previewResult}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isPulling}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
Preview
</Button>
{:else}
<Button
size="xs"
on:click={previewChanges}
disabled={isPreviewLoading || isPulling}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
title="Preview changes again"
>
Preview
</Button>
{#if previewResult.added?.length || previewResult.deleted?.length || previewResult.modified?.length}
<Button
color="red"
size="xs"
on:click={pullFromRepo}
disabled={isPreviewLoading || isPulling}
startIcon={{
icon: isPulling ? Loader2 : Save,
classes: isPulling ? 'animate-spin' : ''
}}
>
{isPulling ? 'Pulling...' : 'Pull'}
</Button>
{/if}
{/if}
</div>
</div>
</div>
</svelte:fragment>
</Popover>
@@ -25,6 +25,7 @@
defaultValues?: Record<string, any> | undefined
placeholder?: string | undefined
onClear?: () => void
excludedValues?: string[]
}
let {
@@ -39,7 +40,8 @@
expressOAuthSetup = false,
defaultValues = undefined,
placeholder = undefined,
onClear = undefined
onClear = undefined,
excludedValues = undefined
}: Props = $props()
if (initialValue && value == undefined) {
@@ -104,6 +106,7 @@
const nc = resources
.flat()
.filter((x) => x.resource_type != 'state' && x.resource_type != 'cache')
.filter((x) => !excludedValues || !excludedValues.includes(x.path))
.map((x) => ({
value: x.path,
label: x.path,
@@ -140,6 +143,13 @@
untrack(() => loadResources(resourceType))
})
$effect(() => {
excludedValues
if ($workspaceStore && resourceType && !disabled) {
untrack(() => loadResources(resourceType))
}
})
let appConnect: AppConnect | undefined = $state()
let resourceEditor: ResourceEditorDrawer | undefined = $state()
let dbManagerDrawer: DbManagerDrawer | undefined = $state()
@@ -2,7 +2,6 @@
import { createEventDispatcher } from 'svelte'
import { fade } from 'svelte/transition'
import Button from '../button/Button.svelte'
import Badge from '../badge/Badge.svelte'
import { twMerge } from 'tailwind-merge'
import CloseButton from '../CloseButton.svelte'
@@ -38,7 +37,7 @@
}
</script>
<svelte:window on:keydown={onKeyDown} />
<svelte:window on:keydown|capture={onKeyDown} />
{#if open}
<!-- svelte-ignore a11y-click-events-have-key-events -->
@@ -98,9 +97,7 @@
color="light"
size="sm"
>
<span class="inline-flex gap-2"
>{cancelText ?? 'Cancel'}<Badge color="dark-gray">Escape</Badge></span
>
{cancelText ?? 'Cancel'}
</Button>
</div>
{/if}
@@ -0,0 +1,196 @@
<script lang="ts">
import { FileSearch, Save, Loader2, CheckCircle2, XCircle } from 'lucide-svelte'
import { Button, Alert } from '$lib/components/common'
import { getGitSyncContext } from './GitSyncContext.svelte'
import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { sendUserToast } from '$lib/toast'
import { workspaceStore } from '$lib/stores'
let { idx } = $props<{ idx: number }>()
const gitSyncContext = getGitSyncContext()
const repo = $derived(gitSyncContext.getRepository(idx))
async function handleDetect() {
try {
await gitSyncContext.detectRepository(idx)
} catch (error: any) {
console.error('Detection failed:', error)
sendUserToast('Detection failed: ' + error.message, true)
}
}
async function handleInitialize() {
if (!repo || repo.detectionState !== 'no-wmill') return
try {
// Show push modal for initialization
gitSyncContext.showPushModal(idx)
} catch (error: any) {
console.error('Failed to initialize repository:', error)
sendUserToast('Failed to initialize repository: ' + error.message, true)
}
}
async function handleSaveConnection() {
if (!repo || repo.detectionState !== 'has-wmill') return
try {
await gitSyncContext.saveRepository(idx)
sendUserToast('Git sync connection saved successfully')
} catch (error: any) {
console.error('Failed to save connection:', error)
sendUserToast('Failed to save connection: ' + error.message, true)
}
}
</script>
{#if repo}
<div class="space-y-4">
{#if !repo.detectionState || repo.detectionState === 'idle'}
<!-- Step 1: Check repo settings button -->
<div class="flex justify-start">
<Button
color="primary"
variant="border"
size="sm"
onclick={handleDetect}
startIcon={{ icon: FileSearch }}
>
Check repo settings
</Button>
</div>
{:else if repo.detectionState === 'loading'}
<!-- Loading state -->
<div class="flex items-center gap-2">
<Loader2 size={16} class="animate-spin" />
<span class="text-sm">Checking repository...</span>
</div>
{:else if repo.detectionState === 'no-wmill'}
<!-- No wmill.yaml found - new repository -->
<Alert type="info" title="Uninitialized Windmill repository found" class="my-2">
No git sync configuration found. Configure your sync settings below.
</Alert>
<GitSyncFilterSettings
git_repo_resource_path={repo.git_repo_resource_path}
bind:include_path={repo.settings.include_path}
bind:include_type={repo.settings.include_type}
bind:exclude_types_override={repo.exclude_types_override}
isLegacyRepo={false}
bind:excludes={repo.settings.exclude_path}
bind:extraIncludes={repo.settings.extra_include_path}
isInitialSetup={true}
requiresMigration={false}
/>
<!-- Toggles for new repositories -->
<div class="space-y-3">
<Toggle
disabled={!repo.git_repo_resource_path}
bind:checked={repo.use_individual_branch}
options={{
right: 'Create one branch per deployed object',
rightTooltip: "If set, Windmill will create a unique branch per object being pushed based on its path, prefixed with 'wm_deploy/'."
}}
/>
<Toggle
disabled={!repo.git_repo_resource_path || !repo.use_individual_branch}
bind:checked={repo.group_by_folder}
options={{
right: 'Group deployed objects by folder',
rightTooltip: 'Instead of creating a branch per object, Windmill will create a branch per folder containing objects being deployed.'
}}
/>
</div>
<!-- Initialize button -->
<div class="flex justify-start">
<Button
size="md"
onclick={handleInitialize}
startIcon={{ icon: Save }}
>
Initialize Git repository
</Button>
</div>
{:else if repo.detectionState === 'has-wmill'}
<!-- wmill.yaml found - existing repository -->
<Alert type="success" title="Existing Windmill repository found" class="my-2">
Found existing git sync configuration. Settings loaded from repository.
</Alert>
<GitSyncFilterSettings
git_repo_resource_path={repo.git_repo_resource_path}
bind:include_path={repo.settings.include_path}
bind:include_type={repo.settings.include_type}
bind:exclude_types_override={repo.exclude_types_override}
isLegacyRepo={false}
bind:excludes={repo.settings.exclude_path}
bind:extraIncludes={repo.settings.extra_include_path}
isInitialSetup={false}
requiresMigration={false}
/>
<!-- Toggles for existing repositories -->
<div class="space-y-3">
<Toggle
disabled={!repo.git_repo_resource_path}
bind:checked={repo.use_individual_branch}
options={{
right: 'Create one branch per deployed object',
rightTooltip: "If set, Windmill will create a unique branch per object being pushed based on its path, prefixed with 'wm_deploy/'."
}}
/>
<Toggle
disabled={!repo.git_repo_resource_path || !repo.use_individual_branch}
bind:checked={repo.group_by_folder}
options={{
right: 'Group deployed objects by folder',
rightTooltip: 'Instead of creating a branch per object, Windmill will create a branch per folder containing objects being deployed.'
}}
/>
</div>
<!-- Save connection button -->
<div class="flex justify-start">
<Button
size="md"
onclick={handleSaveConnection}
startIcon={{ icon: Save }}
>
Save connection
</Button>
</div>
{:else if repo.detectionState === 'error'}
<!-- Error state -->
<Alert type="error" title="Detection error" class="my-2">
{repo.detectionError || 'Failed to check repository'}
</Alert>
{/if}
<!-- Job status display -->
{#if repo.detectionJobId && (repo.detectionState === 'loading' || repo.detectionState === 'error')}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if repo.detectionJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if repo.detectionJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if repo.detectionJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Detection job:
<a
target="_blank"
class="underline"
href={`/run/${repo.detectionJobId}?workspace=${$workspaceStore}`}
>
{repo.detectionJobId}
</a>
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,583 @@
import { getContext, setContext } from 'svelte'
import { JobService, WorkspaceService } from '$lib/gen'
import type { GitRepositorySettings as BackendGitRepositorySettings, GitSyncObjectType } from '$lib/gen'
import { jobManager } from '$lib/services/JobManager'
import hubPaths from '$lib/hubPaths.json'
import type { SettingsObject } from '$lib/git-sync'
export type GitSyncRepository = BackendGitRepositorySettings & {
settings: SettingsObject
exclude_types_override: GitSyncObjectType[]
legacyImported?: boolean
isUnsavedConnection?: boolean
collapsed?: boolean
// Repository detection state for new connections
detectionState?: 'idle' | 'loading' | 'no-wmill' | 'has-wmill' | 'error'
extractedSettings?: SettingsObject
detectionError?: string
// Job tracking for detection
detectionJobId?: string
detectionJobStatus?: 'running' | 'success' | 'failure'
// Internal tracking for resource path changes
_trackedPath?: string
}
export type GitSyncTestJob = {
jobId: string
status: 'running' | 'success' | 'failure' | undefined
}
export type GitSyncSettings = {
repositories: GitSyncRepository[]
}
export type ModalState = {
push: { idx: number, repo: GitSyncRepository, open: boolean } | null
pull: { idx: number, repo: GitSyncRepository, open: boolean, settingsOnly?: boolean } | null
success: { open: boolean, savedWithoutInit?: boolean } | null
}
export type ValidationState = {
isValid: boolean
isDuplicate: boolean
hasChanges: boolean
}
const GIT_SYNC_CONTEXT_KEY = Symbol('git-sync-context')
// Context implementation
export function createGitSyncContext(workspace: string) {
const repositories = $state<GitSyncRepository[]>([])
const initialRepositories = $state<GitSyncRepository[]>([])
const gitSyncTestJobs = $state<GitSyncTestJob[]>([])
let loading = $state(false)
const activeModals = $state<ModalState>({ push: null, pull: null, success: null })
// Legacy workspace-level settings state
const legacyWorkspaceIncludePath = $state<string[]>([])
const legacyWorkspaceIncludeType = $state<GitSyncObjectType[]>([])
// Derived state for legacy detection
const hasWorkspaceLevelSettings = $derived(
legacyWorkspaceIncludePath.length > 0 || legacyWorkspaceIncludeType.length > 0
)
// Watch for changes to git repository paths and reset detection state
$effect(() => {
repositories.forEach((repo) => {
if (repo.isUnsavedConnection) {
const currentPath = repo.git_repo_resource_path
if (repo._trackedPath && repo._trackedPath !== currentPath && repo.detectionState && repo.detectionState !== 'idle') {
_resetRepoDetectionState(repo)
}
repo._trackedPath = currentPath
}
})
})
const getValidationStates = () => {
return repositories.map((repo, idx) => ({
isValid: validateRepository(repo, idx),
isDuplicate: checkDuplicate(repo, idx),
hasChanges: checkChanges(repo, idx)
}))
}
const getHasAnyChanges = () => {
const validationStates = getValidationStates()
// Check if any individual repositories have changes
const individualChanges = validationStates.some(v => v.hasChanges)
// Check if any legacy repos were imported
const anyLegacyImported = repositories.some(r => r.legacyImported)
// Check if the set of repositories has changed (added/removed repos)
const repositorySetChanged = (() => {
if (loading) {
return false
}
if (!initialRepositories || initialRepositories.length === 0) {
return repositories.filter((_,i) => validationStates[i]?.isValid).length > 0
}
const initialValidPaths = new Set(
initialRepositories
.filter(r => r.git_repo_resource_path && r.git_repo_resource_path.trim() !== '')
.map(r => r.git_repo_resource_path)
)
const currentValidPaths = new Set(
repositories
.filter((_,i) => validationStates[i]?.isValid)
.map(r => r.git_repo_resource_path)
)
// Check if sets are different (repos added or removed)
return initialValidPaths.size !== currentValidPaths.size ||
[...initialValidPaths].some(path => !currentValidPaths.has(path)) ||
[...currentValidPaths].some(path => !initialValidPaths.has(path))
})()
return individualChanges || anyLegacyImported || repositorySetChanged
}
const getAllRepositoriesValid = () => getValidationStates().every(v => v.isValid)
const getHasUnsavedConnections = () => repositories.some(repo => repo.isUnsavedConnection)
function validateRepository(repo: GitSyncRepository, idx: number): boolean {
if (!repo.git_repo_resource_path) return false
return !checkDuplicate(repo, idx)
}
function checkDuplicate(repo: GitSyncRepository, idx: number): boolean {
if (!repo.git_repo_resource_path) return false
const firstIdx = repositories.findIndex(r => r.git_repo_resource_path === repo.git_repo_resource_path)
return firstIdx !== -1 && firstIdx < idx
}
function checkChanges(repo: GitSyncRepository, idx: number): boolean {
const initial = initialRepositories[idx]
if (!initial) return true
// Legacy repositories always have "changes" because they need migration
if (repo.legacyImported) return true
return JSON.stringify(serializeRepository(repo)) !== JSON.stringify(serializeRepository(initial))
}
function serializeRepository(repo: GitSyncRepository) {
return {
git_repo_resource_path: repo.git_repo_resource_path,
script_path: repo.script_path,
use_individual_branch: repo.use_individual_branch,
group_by_folder: repo.group_by_folder,
settings: repo.settings,
exclude_types_override: repo.exclude_types_override
}
}
function addRepository() {
repositories.push({
git_repo_resource_path: '',
script_path: hubPaths.gitSync,
use_individual_branch: false,
group_by_folder: false,
settings: {
include_path: ['f/**'],
exclude_path: [],
extra_include_path: [],
include_type: ['script', 'flow', 'app', 'folder']
},
exclude_types_override: [],
legacyImported: false,
isUnsavedConnection: true,
collapsed: false
})
gitSyncTestJobs.push({
jobId: '',
status: undefined
})
}
async function removeRepository(idx: number) {
const repo = repositories[idx]
if (!repo) return
// Check if this repository exists in the initial (saved) state
const existsInInitialState = initialRepositories.some(
initialRepo => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
)
// Only call backend API if repository exists in the saved state
if (existsInInitialState && repo.git_repo_resource_path) {
await WorkspaceService.deleteGitSyncRepository({
workspace,
requestBody: {
git_repo_resource_path: `$res:${repo.git_repo_resource_path}`
}
})
// Update initial state to remove the deleted repository
const initialIdx = initialRepositories.findIndex(
initialRepo => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
)
if (initialIdx !== -1) {
initialRepositories.splice(initialIdx, 1)
}
}
// Remove from local state
repositories.splice(idx, 1)
gitSyncTestJobs.splice(idx, 1)
}
function getRepository(idx: number) {
return repositories[idx]
}
function showPushModal(idx: number) {
const repo = repositories[idx]
if (repo) {
activeModals.push = { idx, repo, open: true }
}
}
function showPullModal(idx: number, settingsOnly = false) {
const repo = repositories[idx]
if (repo) {
activeModals.pull = { idx, repo, open: true, settingsOnly }
}
}
function closeModal(type: 'push' | 'pull' | 'success') {
if (activeModals[type]) {
activeModals[type]!.open = false
}
setTimeout(() => {
activeModals[type] = null
}, 200)
}
function closePushModal() {
closeModal('push')
}
function closePullModal() {
closeModal('pull')
}
function showSuccessModal(savedWithoutInit?: boolean) {
activeModals.success = { open: true, savedWithoutInit }
}
function closeSuccessModal() {
closeModal('success')
}
function getValidation(idx: number): ValidationState {
const states = getValidationStates()
return states[idx] || { isValid: false, isDuplicate: false, hasChanges: false }
}
async function detectRepository(idx: number) {
const repo = repositories[idx]
if (!repo || !repo.git_repo_resource_path) {
throw new Error('Repository not found or no resource path')
}
repo.detectionState = 'loading'
repo.detectionError = undefined
repo.detectionJobId = undefined
repo.detectionJobStatus = undefined
try {
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: {
workspace_id: workspace,
repo_url_resource_path: repo.git_repo_resource_path,
dry_run: true,
pull: false,
only_wmill_yaml: true,
settings_json: JSON.stringify(repo.settings)
},
skipPreprocessor: true
})
repo.detectionJobId = jobId
repo.detectionJobStatus = 'running'
// Use JobManager for polling - result will be the actual job response
await jobManager.runWithProgress(
() => Promise.resolve(jobId),
{
workspace,
timeout: 30000,
timeoutMessage: 'Detection job timed out after 30s',
onProgress: (status) => {
repo.detectionJobStatus = status.status
// Process successful detection result
if (status.status === 'success' && status.result) {
const response = status.result as any
if (response.isInitialSetup) {
repo.detectionState = 'no-wmill'
} else {
repo.detectionState = 'has-wmill'
// Apply extracted settings from the git repository
if (response.local) {
repo.extractedSettings = response.local
// Auto-apply the extracted settings
repo.settings = { ...response.local }
}
}
} else if (status.status === 'failure') {
repo.detectionState = 'error'
repo.detectionError = status.error || 'Detection failed'
}
}
}
)
} catch (error: any) {
repo.detectionState = 'error'
repo.detectionError = error?.message || error?.toString() || 'Failed to detect repository'
repo.detectionJobStatus = 'failure'
}
}
// Settings management
async function loadSettings() {
loading = true
try {
const settings = await WorkspaceService.getSettings({ workspace })
if (settings.git_sync !== undefined && settings.git_sync !== null) {
// Detect workspace-level legacy settings (outside repositories)
const workspaceLegacyIncludePath: string[] = (settings.git_sync as any)?.include_path ?? []
const workspaceLegacyIncludeTypeRaw: GitSyncObjectType[] = (settings.git_sync as any)?.include_type ?? []
const workspaceLegacyIncludeType: GitSyncObjectType[] = [...workspaceLegacyIncludeTypeRaw]
// Update legacy workspace state
legacyWorkspaceIncludePath.splice(0, legacyWorkspaceIncludePath.length, ...workspaceLegacyIncludePath)
legacyWorkspaceIncludeType.splice(0, legacyWorkspaceIncludeType.length, ...workspaceLegacyIncludeType)
if (settings.git_sync.repositories) {
repositories.splice(0, repositories.length, ...settings.git_sync.repositories.map(repo => {
// Check if this is a legacy repo (no nested settings object)
const isRepoLegacy = !repo.settings
const repoExcludeTypesOverride = repo.exclude_types_override ?? []
// Determine default types - use workspace legacy or fallback
const defaultTypes: GitSyncObjectType[] = workspaceLegacyIncludeType.length > 0
? [...workspaceLegacyIncludeType]
: ['script', 'flow', 'app', 'folder']
let repoSettings: SettingsObject
if (isRepoLegacy) {
// Legacy repo: inherit from workspace-level settings and apply exclude_types_override
const inheritedIncludeType = repo.settings?.include_type ?? [...defaultTypes]
const effectiveIncludeType = repoExcludeTypesOverride.length > 0
? inheritedIncludeType.filter(type => !repoExcludeTypesOverride.includes(type))
: inheritedIncludeType
repoSettings = {
include_path: repo.settings?.include_path ?? [...workspaceLegacyIncludePath],
exclude_path: repo.settings?.exclude_path ?? [],
extra_include_path: repo.settings?.extra_include_path ?? [],
include_type: effectiveIncludeType
}
} else {
// New format: use repo's own settings
repoSettings = {
include_path: repo.settings?.include_path ?? ['f/**'],
exclude_path: repo.settings?.exclude_path ?? [],
extra_include_path: repo.settings?.extra_include_path ?? [],
include_type: repo.settings?.include_type ?? ['script', 'flow', 'app']
}
}
return {
...repo,
git_repo_resource_path: repo.git_repo_resource_path.replace('$res:', ''),
settings: repoSettings,
exclude_types_override: repoExcludeTypesOverride,
// Mark legacy repos for UI handling
legacyImported: isRepoLegacy
}
}))
}
}
// Store initial state for change tracking
initialRepositories.splice(0, initialRepositories.length, ...repositories.map(repo => ({ ...repo })))
} finally {
loading = false
}
}
// Migration utility for legacy repositories
function migrateLegacyRepository(repo: GitSyncRepository): GitSyncRepository {
if (!repo.legacyImported) {
return repo // Already migrated or not legacy
}
// Create migrated repository - exclude_types_override should already be applied in settings.include_type
// from the loadSettings logic, so we just need to clear the override and mark as migrated
return {
...repo,
exclude_types_override: [], // Clear the override since it's now integrated into include_type
legacyImported: false // Mark as migrated
}
}
async function saveRepository(idx: number, savedWithoutInit = false) {
const repo = repositories[idx]
if (!repo || !validateRepository(repo, idx)) {
throw new Error('Cannot save invalid repository')
}
// Migrate legacy repository if needed
const repoToSave = repo.legacyImported ? migrateLegacyRepository(repo) : repo
// Use the new individual repository API instead of saving all repositories
await WorkspaceService.editGitSyncRepository({
workspace,
requestBody: {
git_repo_resource_path: `$res:${repoToSave.git_repo_resource_path}`,
repository: {
git_repo_resource_path: `$res:${repoToSave.git_repo_resource_path}`,
script_path: repoToSave.script_path,
use_individual_branch: repoToSave.use_individual_branch,
group_by_folder: repoToSave.group_by_folder,
settings: repoToSave.settings,
exclude_types_override: repoToSave.exclude_types_override
}
}
})
// Update local state with migrated repository
repositories[idx] = repoToSave
initialRepositories[idx] = { ...repoToSave }
// Update local state
if (repoToSave.isUnsavedConnection) {
repoToSave.isUnsavedConnection = false
repoToSave.detectionState = undefined
repoToSave.extractedSettings = undefined
// Show success modal for new connections
showSuccessModal(savedWithoutInit)
}
}
// Helper functions for original functionality
function revertRepository(idx: number) {
const initial = initialRepositories[idx]
if (initial) {
repositories[idx] = JSON.parse(JSON.stringify(initial))
}
}
// Reset detection state for a repository
function resetDetectionState(idx: number) {
const repo = repositories[idx]
if (!repo || !repo.isUnsavedConnection) return
_resetRepoDetectionState(repo)
}
// Helper function to reset detection state on a repository object
function _resetRepoDetectionState(repo: GitSyncRepository) {
repo.detectionState = 'idle'
repo.extractedSettings = undefined
repo.detectionError = undefined
repo.detectionJobId = undefined
repo.detectionJobStatus = undefined
}
async function runTestJob(idx: number) {
const repo = repositories[idx]
if (!repo?.git_repo_resource_path || !repo?.script_path) {
return
}
try {
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitSyncTest,
requestBody: {
repo_url_resource_path: repo.git_repo_resource_path
},
skipPreprocessor: true
})
gitSyncTestJobs[idx] = {
jobId: jobId,
status: 'running'
}
// Use JobManager for polling
await jobManager.runWithProgress(
() => Promise.resolve(jobId),
{
workspace,
timeout: 5000,
timeoutMessage: 'Git sync test job timed out after 5s',
onProgress: (status) => {
gitSyncTestJobs[idx].status = status.status === 'success' ? 'success' :
status.status === 'failure' ? 'failure' : 'running'
}
}
)
// If we get here, the job completed successfully
gitSyncTestJobs[idx].status = 'success'
} catch (error) {
gitSyncTestJobs[idx].status = 'failure'
}
}
// Return context object
return {
// State (read-only access)
get repositories() { return repositories },
get loading() { return loading },
get activeModals() { return activeModals },
get gitSyncTestJobs() { return gitSyncTestJobs },
get initialRepositories() { return initialRepositories },
get legacyWorkspaceIncludePath() { return legacyWorkspaceIncludePath },
get legacyWorkspaceIncludeType() { return legacyWorkspaceIncludeType },
// Computed states - use getter functions that compute on access
get validationStates() { return getValidationStates() },
get hasAnyChanges() { return getHasAnyChanges() },
get allRepositoriesValid() { return getAllRepositoriesValid() },
get hasUnsavedConnections() { return getHasUnsavedConnections() },
get hasWorkspaceLevelSettings() { return hasWorkspaceLevelSettings },
// Methods
addRepository,
removeRepository,
getRepository,
getValidation,
revertRepository,
runTestJob,
resetDetectionState,
detectRepository,
migrateLegacyRepository,
showPushModal,
showPullModal,
closePushModal,
closePullModal,
showSuccessModal,
closeSuccessModal,
loadSettings,
saveRepository,
}
}
export type GitSyncContextType = ReturnType<typeof createGitSyncContext>
export function setGitSyncContext(workspace: string): GitSyncContextType {
const context = createGitSyncContext(workspace)
setContext(GIT_SYNC_CONTEXT_KEY, context)
return context
}
export function getGitSyncContext(): GitSyncContextType {
const context = getContext<GitSyncContextType>(GIT_SYNC_CONTEXT_KEY)
if (!context) {
throw new Error('Git sync context not found. Make sure to call setGitSyncContext first.')
}
return context
}
@@ -0,0 +1,108 @@
<script lang="ts">
import { getGitSyncContext } from './GitSyncContext.svelte'
import PushWorkspaceModal from '$lib/components/git_sync/PushWorkspaceModal.svelte'
import PullWorkspaceModal from '$lib/components/git_sync/PullWorkspaceModal.svelte'
import GitSyncSuccessModal from '$lib/components/git_sync/GitSyncSuccessModal.svelte'
import { sendUserToast } from '$lib/toast'
const gitSyncContext = getGitSyncContext()
function handlePushSuccess() {
const pushModal = gitSyncContext.activeModals.push
if (!pushModal) return
const { idx, repo } = pushModal
// If this was a repository initialization, auto-save the connection
if (repo.isUnsavedConnection && repo.detectionState === 'no-wmill') {
gitSyncContext.saveRepository(idx).then(() => {
sendUserToast('Repository initialized and connection saved successfully')
}).catch((error) => {
sendUserToast('Repository initialized but failed to save connection: ' + error.message, true)
})
} else {
sendUserToast('Successfully pushed to git repository')
}
gitSyncContext.closePushModal()
}
function handlePullSuccess() {
sendUserToast('Successfully pulled from git repository')
gitSyncContext.closePullModal()
}
function handleFilterUpdate(idx: number, filters: any) {
// Update the repository settings in the context
const repo = gitSyncContext.getRepository(idx)
if (repo) {
repo.settings = filters
}
}
function handleSettingsSaved() {
// Update initial state to reflect that current state has been saved externally
gitSyncContext.initialRepositories.splice(0, gitSyncContext.initialRepositories.length, ...gitSyncContext.repositories.map(repo => ({ ...repo })))
sendUserToast('Settings applied successfully')
}
async function handleSaveWithoutInit(idx: number) {
try {
await gitSyncContext.saveRepository(idx, true)
sendUserToast('Connection saved successfully without initializing repository')
gitSyncContext.closePushModal()
} catch (error: any) {
sendUserToast('Failed to save connection: ' + error.message, true)
}
}
</script>
<!-- Push Modal -->
{#if gitSyncContext.activeModals.push}
{@const { idx, repo } = gitSyncContext.activeModals.push}
{@const isNewConnection = repo.isUnsavedConnection && repo.detectionState === 'no-wmill'}
<PushWorkspaceModal
bind:open={gitSyncContext.activeModals.push.open}
gitRepoResourcePath={repo.git_repo_resource_path}
uiState={{
include_path: repo.settings.include_path,
exclude_path: repo.settings.exclude_path,
extra_include_path: repo.settings.extra_include_path,
include_type: repo.settings.include_type
}}
isNewConnection={isNewConnection}
onSuccess={handlePushSuccess}
onSaveWithoutInit={isNewConnection ? () => handleSaveWithoutInit(idx) : undefined}
/>
{/if}
<!-- Pull Modal -->
{#if gitSyncContext.activeModals.pull}
{@const { idx, repo, settingsOnly } = gitSyncContext.activeModals.pull}
<PullWorkspaceModal
bind:open={gitSyncContext.activeModals.pull.open}
gitRepoResourcePath={repo.git_repo_resource_path}
repoIndex={idx}
currentGitSyncSettings={gitSyncContext}
uiState={{
include_path: repo.settings.include_path,
exclude_path: repo.settings.exclude_path,
extra_include_path: repo.settings.extra_include_path,
include_type: repo.settings.include_type
}}
onFilterUpdate={(filters) => handleFilterUpdate(idx, filters)}
onSettingsSaved={handleSettingsSaved}
onSuccess={handlePullSuccess}
{settingsOnly}
/>
{/if}
<!-- Success Modal -->
{#if gitSyncContext.activeModals.success}
<GitSyncSuccessModal
bind:open={gitSyncContext.activeModals.success.open}
savedWithoutInit={gitSyncContext.activeModals.success.savedWithoutInit}
/>
{/if}
@@ -0,0 +1,361 @@
<script lang="ts">
import { Save, Trash, XCircle, CheckCircle2, RotateCw, RotateCcw, Download, Upload } from 'lucide-svelte'
import { Button, Alert } from '$lib/components/common'
import { getGitSyncContext } from './GitSyncContext.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte'
import DetectionFlow from './DetectionFlow.svelte'
import { sendUserToast } from '$lib/toast'
import { fade } from 'svelte/transition'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
let { idx } = $props<{ idx: number }>()
const gitSyncContext = getGitSyncContext()
const repo = $derived(gitSyncContext.getRepository(idx))
const validation = $derived(gitSyncContext.getValidation(idx))
const gitSyncTestJob = $derived(gitSyncContext.gitSyncTestJobs?.[idx])
let confirmingDelete = $state(false)
// Compute already-used repository paths to exclude from picker
const usedRepositoryPaths = $derived(
gitSyncContext.repositories
.map((r, i) => i !== idx ? r.git_repo_resource_path : null)
.filter((path): path is string => Boolean(path?.trim()))
)
async function handleSave() {
if (!repo) return
try {
await gitSyncContext.saveRepository(idx)
sendUserToast('Repository settings updated')
} catch (error: any) {
console.error('Failed to save repository:', error)
sendUserToast('Failed to save repository: ' + error.message, true)
}
}
function handleRevert() {
if (!repo) return
try {
gitSyncContext.revertRepository?.(idx)
sendUserToast('Reverted repository settings')
} catch (error: any) {
console.error('Failed to revert repository:', error)
sendUserToast('Failed to revert repository: ' + error.message, true)
}
}
function initiateDelete() {
confirmingDelete = true
}
async function confirmDelete() {
try {
await gitSyncContext.removeRepository(idx)
sendUserToast('Repository connection removed successfully')
} catch (error: any) {
console.error('Failed to remove repository:', error)
sendUserToast('Failed to remove repository: ' + error.message, true)
} finally {
confirmingDelete = false
}
}
function cancelDelete() {
confirmingDelete = false
}
function runGitSyncTestJob() {
if (gitSyncContext.runTestJob) {
gitSyncContext.runTestJob(idx)
}
}
function emptyString(str: string | undefined | null): boolean {
return !str || str.trim() === ''
}
function handlePullSettings() {
gitSyncContext.showPullModal(idx, true) // true for settingsOnly
}
</script>
{#if repo}
<div class="rounded-lg shadow-sm border p-0 w-full mb-4">
<!-- Card Header -->
<div class="flex items-center justify-between min-h-10 px-4 py-1 border-b">
<div class="flex items-center gap-2">
<span class="font-semibold">Repository #{idx + 1}</span>
{#if repo.legacyImported}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-orange-100 text-orange-800 border border-orange-200">
Legacy Configuration
</span>
{/if}
<span class="text-xs text-tertiary pt-1 pl-8">
{repo.git_repo_resource_path}
</span>
</div>
<div class="flex items-center gap-2">
{#if validation.hasChanges && validation.isValid && !repo.isUnsavedConnection}
<Button
size="xs"
onclick={handleSave}
startIcon={{ icon: Save }}
>
{repo.legacyImported ? 'Migrate and save' : 'Save changes'}
</Button>
{#if gitSyncContext.initialRepositories[idx] && !repo.legacyImported}
<Button
color="light"
size="xs"
onclick={handleRevert}
startIcon={{ icon: RotateCcw }}
>
Revert
</Button>
{/if}
{/if}
<button
class="text-secondary hover:text-primary focus:outline-none"
onclick={() => (repo.collapsed = !repo.collapsed)}
aria-label="Toggle collapse"
>
{#if repo.collapsed}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
{/if}
</button>
{#if !confirmingDelete}
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-2 bg-surface-secondary duration-200 hover:bg-surface-hover"
aria-label="Remove repository"
onclick={initiateDelete}
>
<Trash size={14} />
</button>
{:else}
<div class="flex gap-1">
<button
transition:fade|local={{ duration: 100 }}
class="px-3 py-1 text-xs bg-red-500 text-white rounded duration-200 hover:bg-red-600"
onclick={confirmDelete}
>
Confirm delete
</button>
<button
transition:fade|local={{ duration: 100 }}
class="px-2 py-1 text-xs bg-surface-secondary rounded duration-200 hover:bg-surface-hover"
onclick={cancelDelete}
>
<XCircle size={12} />
</button>
</div>
{/if}
</div>
</div>
{#if !repo.collapsed}
<div class="px-4 py-2">
<div class="flex mt-5 mb-1 gap-1">
{#key repo}
<div class="pt-1 font-semibold">Resource: </div>
<ResourcePicker
bind:value={repo.git_repo_resource_path}
resourceType={'git_repository'}
disabled={!repo.isUnsavedConnection}
excludedValues={usedRepositoryPaths}
/>
{#if !emptyString(repo.git_repo_resource_path)}
<Button
disabled={emptyString(repo.script_path)}
color="dark"
onclick={runGitSyncTestJob}
size="xs">Test connection</Button
>
{/if}
{/key}
</div>
{#if !emptyString(repo.git_repo_resource_path)}
<div class="flex mb-5 text-normal text-2xs gap-1">
{#if validation.isDuplicate}
<span class="text-red-600">This resource is already used by another repository.</span>
{/if}
{#if gitSyncTestJob && gitSyncTestJob.status !== undefined}
{#if gitSyncTestJob.status === 'running'}
<RotateCw size={14} class="animate-spin" />
{:else if gitSyncTestJob.status === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-700" />
{/if}
Git sync resource checked via Windmill job
<a
target="_blank"
href={`/run/${gitSyncTestJob.jobId}?workspace=${$workspaceStore}`}
>
{gitSyncTestJob.jobId}
</a>WARNING: Only read permissions are verified.
{/if}
</div>
{#if repo.legacyImported}
<Alert type="warning" title="Legacy git sync settings imported">
This repository was initialized from workspace-level legacy Git-Sync settings. Review the filters and press <b>Save</b> to migrate.
</Alert>
{/if}
<div class="flex flex-col mt-5 mb-1 gap-4">
{#if repo}
{#if repo.script_path != hubPaths.gitSync}
<Alert type="warning" title="Script version mismatch">
The git sync version for this repository is not latest. Current: <a
target="_blank"
href="https://hub.windmill.dev/scripts/windmill/6943/sync-script-to-git-repo-windmill/9014/versions"
>{repo.script_path}</a
>, latest:
<a
target="_blank"
href="https://hub.windmill.dev/scripts/windmill/6943/sync-script-to-git-repo-windmill/9014/versions"
>{hubPaths.gitSync}</a
>
<div class="flex mt-2">
<Button
size="xs"
color="dark"
onclick={() => {
if (repo) {
repo.script_path = hubPaths.gitSync
}
}}
>Update git sync script (require save git settings to be applied)</Button
>
</div>
</Alert>
{/if}
{#if repo.isUnsavedConnection && !emptyString(repo.git_repo_resource_path)}
<!-- Use DetectionFlow component -->
<div class="mt-4">
<DetectionFlow {idx} />
</div>
{:else}
<!-- Existing saved connection flow -->
<GitSyncFilterSettings
bind:git_repo_resource_path={repo.git_repo_resource_path}
bind:include_path={repo.settings.include_path}
bind:include_type={repo.settings.include_type}
bind:exclude_types_override={repo.exclude_types_override}
isLegacyRepo={repo.legacyImported}
bind:excludes={repo.settings.exclude_path}
bind:extraIncludes={repo.settings.extra_include_path}
isInitialSetup={false}
requiresMigration={repo.legacyImported}
>
{#snippet actions()}
<Button
size="md"
onclick={handlePullSettings}
startIcon={{ icon: Download }}
>
Pull settings
</Button>
{/snippet}
</GitSyncFilterSettings>
{/if}
{#if !repo.isUnsavedConnection}
<div class="flex justify-between items-start">
<div class="flex flex-col gap-4">
<Toggle
disabled={emptyString(repo.git_repo_resource_path)}
bind:checked={repo.use_individual_branch}
options={{
right: 'Create one branch per deployed object',
rightTooltip:
"If set, Windmill will create a unique branch per object being pushed based on its path, prefixed with 'wm_deploy/'."
}}
/>
<Toggle
disabled={emptyString(repo.git_repo_resource_path) ||
!repo.use_individual_branch}
bind:checked={repo.group_by_folder}
options={{
right: 'Group deployed objects by folder',
rightTooltip:
'Instead of creating a branch per object, Windmill will create a branch per folder containing objects being deployed.'
}}
/>
</div>
<!-- Manual sync section for existing repos -->
{#if !emptyString(repo.git_repo_resource_path) && !repo.legacyImported}
<div class="flex flex-col">
<div class="text-sm text-secondary mb-2">Manual workspace content sync</div>
<div class="flex gap-2">
<Button
size="xs"
color="dark"
variant="border"
onclick={() => gitSyncContext.showPullModal(idx)}
startIcon={{ icon: Download }}
>
Pull from repo
</Button>
<Button
size="xs"
color="dark"
variant="border"
onclick={() => gitSyncContext.showPushModal(idx)}
startIcon={{ icon: Upload }}
>
Push to repo
</Button>
</div>
</div>
{/if}
</div>
{/if}
{/if}
</div>
{:else}
<div class="text-xs text-tertiary pt-1 pl-8">Please select a Git repository resource.</div>
{/if}
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,19 @@
<script lang="ts">
import { getGitSyncContext } from './GitSyncContext.svelte'
import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte'
import { Alert } from '$lib/components/common'
const gitSyncContext = getGitSyncContext()
</script>
<div class="flex flex-col gap-4">
{#if gitSyncContext.repositories.length === 0}
<Alert type="info" title="No repositories configured">
Add your first Git repository to start syncing your workspace.
</Alert>
{:else}
{#each gitSyncContext.repositories as repository, idx (repository.git_repo_resource_path || idx)}
<GitSyncRepositoryCard {idx} />
{/each}
{/if}
</div>
@@ -0,0 +1,93 @@
<script lang="ts">
import { Plus, ExternalLink } from 'lucide-svelte'
import { Button, Alert } from '$lib/components/common'
import Description from '$lib/components/Description.svelte'
import { setGitSyncContext } from './GitSyncContext.svelte'
import GitSyncRepositoryList from './GitSyncRepositoryList.svelte'
import GitSyncModalManager from './GitSyncModalManager.svelte'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { untrack } from 'svelte'
// Create context reactively based on workspaceStore
const gitSyncContext = $derived($workspaceStore ? setGitSyncContext($workspaceStore) : null)
// Load settings when workspace context changes
$effect(() => {
if (gitSyncContext) {
untrack(async () => {
try {
await gitSyncContext.loadSettings()
} catch (error) {
console.error('Failed to load git sync settings:', error)
sendUserToast('Failed to load git sync settings', true)
}
})
}
})
</script>
{#if !gitSyncContext}
<div class="flex items-center justify-center p-8">
<div class="text-sm text-secondary">Loading workspace...</div>
</div>
{:else if gitSyncContext.loading}
<div class="flex items-center justify-center p-8">
<div class="text-sm text-secondary">Loading git sync settings...</div>
</div>
{:else}
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-primary text-lg font-semibold">Git Sync</div>
<Description link="https://www.windmill.dev/docs/advanced/git_sync">
Connect the Windmill workspace to a Git repository to automatically commit and push
scripts, flows, and apps to the repository on each deploy.
</Description>
</div>
<Alert type="info" title="Only new updates trigger git sync">
Only new changes matching the filters will trigger a git sync. You still need to initialize
the repo to the desired state first.
</Alert>
</div>
{#if !$enterpriseLicense}
<div class="mb-2"></div>
<Alert type="warning" title="Syncing workspace to Git is an EE feature">
Automatically saving scripts to a Git repository on each deploy is a Windmill EE feature.
</Alert>
<div class="mb-2"></div>
{/if}
{#if $enterpriseLicense && gitSyncContext.repositories != undefined}
<div class="flex mt-5 mb-5 gap-8">
<Button
color="dark"
target="_blank"
endIcon={{ icon: ExternalLink }}
href={`/runs?job_kinds=deploymentcallbacks&workspace=${$workspaceStore}`}
>
See sync jobs
</Button>
</div>
<div class="pt-2"></div>
<!-- Repository list -->
<GitSyncRepositoryList />
<!-- Add repository button -->
<div class="flex mt-5 mb-5">
<Button
startIcon={{ icon: Plus }}
color="dark"
variant="border"
onclick={() => gitSyncContext.addRepository()}
>
Add connection
</Button>
</div>
<!-- Modals -->
<GitSyncModalManager />
{/if}
{/if}
@@ -0,0 +1,69 @@
<script lang="ts">
import Modal from '$lib/components/common/modal/Modal.svelte'
import { CheckCircle2, ExternalLink, ArrowRight } from 'lucide-svelte'
interface Props {
open: boolean
savedWithoutInit?: boolean
}
let {
open = $bindable(false),
savedWithoutInit = false
}: Props = $props()
</script>
<Modal bind:open title="Git Sync Connection Saved" class="sm:max-w-4xl" cancelText="Close">
<div class="flex flex-col gap-6 p-6">
<!-- Success header -->
<div class="flex items-center gap-3">
<div class="flex-shrink-0">
<CheckCircle2 class="h-8 w-8 text-green-600" />
</div>
<div>
<h3 class="text-lg font-semibold text-primary">Git sync connection saved successfully!</h3>
<p class="text-sm text-secondary mt-1">Your repository is now configured to receive changes from Windmill.</p>
</div>
</div>
<!-- Info box for saved without init -->
{#if savedWithoutInit}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 class="font-medium text-blue-900 mb-2">Repository saved without initialization</h4>
<p class="text-sm text-blue-800">
Only new changes will be pushed to this repository. Existing content in Windmill has not been initialized to the repository.
</p>
</div>
{/if}
<!-- Optional setup section -->
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 class="font-medium text-amber-900 mb-2 flex items-center gap-2">
<ArrowRight class="h-4 w-4" />
Optional: Enable automatic deployment from Git to Windmill
</h4>
<p class="text-sm text-amber-800 mb-3">
To automatically deploy changes from your Git repository back to Windmill (when PRs are merged), you can set up GitHub Actions or similar CI/CD workflows.
</p>
<div class="flex flex-col gap-2">
<p class="text-sm text-amber-700">This setup enables:</p>
<ul class="text-sm text-amber-700 ml-4 list-disc space-y-1">
<li>Automatic deployment to Windmill when PRs are merged</li>
<li>Full bidirectional sync between Git and Windmill</li>
</ul>
<div class="mt-3">
<a
href="https://www.windmill.dev/docs/advanced/deploy_gh_gl#github-actions-setup"
target="_blank"
class="text-sm text-amber-700 hover:text-amber-900 underline flex items-center gap-1"
>
<ExternalLink class="h-3 w-3" />
Learn more
</a>
</div>
</div>
</div>
</div>
</Modal>
@@ -0,0 +1,538 @@
<script lang="ts">
import Modal from '$lib/components/common/modal/Modal.svelte'
import { Button, Alert, Badge } from '$lib/components/common'
import { Loader2, CheckCircle2, XCircle, Terminal, ChevronDown, ChevronUp, Save } from 'lucide-svelte'
import GitDiffPreview from '../GitDiffPreview.svelte'
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import hubPaths from '$lib/hubPaths.json'
import { tryEvery } from '$lib/utils'
import type { SyncResponse, SettingsResponse, SettingsObject } from '$lib/git-sync'
interface Props {
open: boolean
gitRepoResourcePath: string
uiState: SettingsObject
repoIndex?: number
currentGitSyncSettings?: any
onFilterUpdate?: (filters: SettingsObject) => void
onSettingsSaved?: () => void
onSuccess?: () => void
settingsOnly?: boolean
}
let {
open = $bindable(false),
gitRepoResourcePath,
uiState,
repoIndex,
currentGitSyncSettings,
onFilterUpdate,
onSettingsSaved,
onSuccess,
settingsOnly = false
}: Props = $props()
// Job state
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isPreviewLoading = $state(false)
let previewError = $state('')
let applyJobId = $state<string | null>(null)
let applyJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isApplying = $state(false)
let applyError = $state('')
// UI state
let showCliInstructions = $state(false)
let previewResult = $state<SyncResponse | SettingsResponse | null>(null)
let settingsApplied = $state(false)
// Helper functions to reduce type casting repetition
const getSettingsChanges = (result: SyncResponse | SettingsResponse | null) => {
if (!result) return { hasChanges: false, data: null, diff: null }
if (settingsOnly) {
const settingsResponse = result as SettingsResponse
return {
hasChanges: settingsResponse.hasChanges ?? false,
data: settingsResponse.local,
diff: settingsResponse
}
} else {
const syncResponse = result as SyncResponse
return {
hasChanges: syncResponse.settingsDiffResult?.hasChanges ?? false,
data: syncResponse.settingsDiffResult?.local,
diff: syncResponse.settingsDiffResult
}
}
}
const getWorkspaceChanges = (result: SyncResponse | SettingsResponse | null) => {
if (!result || settingsOnly) return { hasChanges: false, changes: [] }
const syncResponse = result as SyncResponse
return {
hasChanges: (syncResponse.changes?.length ?? 0) > 0,
changes: syncResponse.changes ?? []
}
}
// Auto-save settings after successful pull with settings updates
async function saveUpdatedSettings() {
if (!currentGitSyncSettings || repoIndex === undefined) return
try {
// Save only the specific repository that was updated
await currentGitSyncSettings.saveRepository(repoIndex)
onSettingsSaved?.()
} catch (error) {
console.error('Failed to save settings:', error)
sendUserToast('Failed to save updated settings', true)
}
}
// Reset state when modal opens/closes
$effect(() => {
if (!open) {
previewJobId = null
previewJobStatus = undefined
isPreviewLoading = false
previewError = ''
applyJobId = null
applyJobStatus = undefined
isApplying = false
applyError = ''
showCliInstructions = false
previewResult = null
settingsApplied = false
} else if (settingsOnly && !previewResult && !isPreviewLoading) {
// Auto-trigger settings preview when modal opens in settings-only mode
executeJob(true, true)
}
})
// Execute job with dry run or actual execution
async function executeJob(isDryRun: boolean, settingsOnly: boolean = false) {
const isPreview = isDryRun
if (isPreview) {
isPreviewLoading = true
previewError = ''
previewResult = null
previewJobId = null
previewJobStatus = undefined
} else {
isApplying = true
applyError = ''
applyJobId = null
applyJobStatus = undefined
}
try {
const workspace = $workspaceStore
if (!workspace) return
const payload = {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: isDryRun,
pull: true,
only_wmill_yaml: settingsOnly,
settings_json: JSON.stringify(uiState)
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payload,
skipPreprocessor: true
})
if (isPreview) {
previewJobId = jobId
previewJobStatus = 'running'
} else {
applyJobId = jobId
applyJobStatus = 'running'
}
let jobSuccess = false
let result: any = {}
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
jobSuccess = !!testResult.success
if (jobSuccess) {
const jobResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
result = jobResult
}
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: `${isPreview ? 'Preview' : 'Apply'} job timed out after 60s` }
})
} catch (err) {}
},
interval: 500,
timeout: 60000
})
if (isPreview) {
previewJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
previewResult = result
} else {
previewError = 'Preview failed'
}
} else {
applyJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
const settingsData = result?.local
const hasSettingsChanges = settingsData && onFilterUpdate
if (hasSettingsChanges) {
onFilterUpdate(settingsData)
await saveUpdatedSettings()
}
onSuccess?.()
} else {
applyError = 'Pull failed'
}
}
} catch (e) {
const errorMsg = e?.message || 'Operation failed'
if (isPreview) {
previewJobStatus = 'failure'
previewError = errorMsg
} else {
applyJobStatus = 'failure'
applyError = errorMsg
}
} finally {
if (isPreview) {
isPreviewLoading = false
} else {
isApplying = false
}
}
}
// Apply settings only (no job needed - we have the data from preview)
async function applySettingsOnly() {
isApplying = true
try {
const settingsChanges = getSettingsChanges(previewResult)
if (!settingsChanges.hasChanges) {
sendUserToast('No settings changes to apply', true)
return
}
if (!settingsChanges.data) {
sendUserToast('Settings data not available', true)
return
}
// Update the UI state with the new settings
if (onFilterUpdate) {
onFilterUpdate(settingsChanges.data)
}
// Save the updated settings
await saveUpdatedSettings()
if (settingsOnly) {
// Settings-only mode - we're done, onSuccess will handle the toast
onSuccess?.()
} else {
// Two-step flow - transition to step 2
settingsApplied = true
sendUserToast('Settings applied successfully. You can now review workspace changes.')
}
} catch (error: any) {
console.error('Failed to apply settings:', error)
sendUserToast('Failed to apply settings: ' + error.message, true)
} finally {
isApplying = false
}
}
</script>
<Modal bind:open title={settingsOnly ? "Pull Settings from Git Repository" : "Pull Workspace from Git Repository"} class="sm:max-w-4xl" cancelText={settingsOnly && !getSettingsChanges(previewResult).hasChanges ? "Close" : "Cancel"}>
<div class="flex flex-col gap-4">
<!-- Description -->
<p class="text-sm text-secondary">
{#if settingsOnly}
Pull and apply settings changes from the Git repository to your workspace. This will update your sync filter settings only.
{:else}
Pull and apply changes from the Git repository to your workspace. If settings changes are detected, you can choose to pull just the settings or everything.
{/if}
</p>
<!-- Warning about overwrites - only show for full pulls, not settings-only -->
{#if !settingsOnly}
<Alert type="warning" title="This will overwrite local changes">
Pulling from the repository will overwrite any local changes to files that exist in the repository.
Make sure to preview the changes before applying.
</Alert>
{/if}
<!-- Preview section -->
{#if !previewResult}
<div class="flex justify-start pt-4">
<Button
size="md"
color="dark"
onclick={() => executeJob(true, settingsOnly)}
disabled={isPreviewLoading}
startIcon={{
icon: isPreviewLoading ? Loader2 : undefined,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
{isPreviewLoading ? 'Previewing...' : 'Preview changes'}
</Button>
</div>
{/if}
<!-- Job status for preview -->
{#if previewJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if previewJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if previewJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if previewJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Preview job:
<a
target="_blank"
class="underline"
href={`/run/${previewJobId}?workspace=${$workspaceStore}`}
>
{previewJobId}
</a>
</div>
{/if}
<!-- Preview error -->
{#if previewError}
<Alert type="error" title="Preview failed">
{previewError}
</Alert>
{/if}
<!-- Preview results -->
{#if previewResult && !previewError}
{@const settingsChanges = getSettingsChanges(previewResult)}
{@const workspaceChanges = getWorkspaceChanges(previewResult)}
<div class="space-y-4">
<!-- Settings changes (always show first if present) -->
{#if settingsChanges.hasChanges && !settingsApplied}
<div>
<h4 class="text-sm font-semibold text-primary mb-2">
Filter Settings from Repository
<Badge color="blue" size="xs" class="ml-2">wmill.yaml</Badge>
</h4>
<div class="bg-surface-secondary rounded-lg p-4 space-y-1">
{#if settingsChanges.diff?.diff}
{#each Object.entries(settingsChanges.diff.diff) as [field, change]}
{@const fieldName = field.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
{@const typedChange = change as {from: any, to: any}}
<div class="flex items-center gap-2 text-xs">
<span class="text-tertiary min-w-0 flex-shrink-0">{fieldName}:</span>
{#if Array.isArray(typedChange.from) && Array.isArray(typedChange.to)}
<span class="text-red-600">{typedChange.from.length === 0 ? 'None' : typedChange.from.join(', ')}</span>
<span class="text-tertiary"></span>
<span class="text-green-600">{typedChange.to.length === 0 ? 'None' : typedChange.to.join(', ')}</span>
{:else}
<span class="text-red-600">{typedChange.from}</span>
<span class="text-tertiary"></span>
<span class="text-green-600">{typedChange.to}</span>
{/if}
</div>
{/each}
{:else}
<div class="text-xs text-tertiary">
Settings changes detected but no detailed diff available.
</div>
{/if}
</div>
</div>
{/if}
<!-- No settings changes detected (settings-only mode) -->
{#if settingsOnly && !settingsChanges.hasChanges}
<div class="bg-surface-secondary rounded-lg p-3">
<div class="text-sm text-tertiary">
No settings changes detected. Your local sync filter settings are already up to date with the repository.
</div>
</div>
{/if}
<!-- Workspace changes (show when no settings changes and there are workspace changes) -->
{#if !settingsOnly && !settingsChanges.hasChanges && workspaceChanges.hasChanges}
<div class={settingsChanges.hasChanges && settingsApplied ? 'border-t pt-4' : ''}>
<h4 class="text-sm font-semibold text-primary mb-2">Workspace changes to pull</h4>
{#if workspaceChanges.hasChanges}
<GitDiffPreview previewResult={previewResult as SyncResponse} />
{:else}
<div class="bg-surface-secondary rounded-lg p-3">
<div class="text-sm text-tertiary">No changes to pull from the repository.</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Apply section (shown after successful preview) -->
{#if previewResult && !previewError}
{@const settingsChanges = getSettingsChanges(previewResult)}
{@const workspaceChanges = getWorkspaceChanges(previewResult)}
{#if settingsChanges.hasChanges || workspaceChanges.hasChanges}
<div class="border-t pt-4 mt-4">
{#if settingsChanges.hasChanges && workspaceChanges.hasChanges && !settingsApplied}
<!-- Step 1: Settings changes first when both are present -->
<div class="flex flex-col gap-3">
<div class="text-sm font-medium text-primary">Step 1 of 2: Apply settings changes</div>
<div class="text-xs text-tertiary">Settings changes detected. Apply these first to ensure workspace content is pulled with the correct configuration.</div>
<div class="flex gap-2">
<Button
size="md"
onclick={applySettingsOnly}
disabled={isApplying}
startIcon={{
icon: isApplying ? Loader2 : Save,
classes: isApplying ? 'animate-spin' : ''
}}
>
{isApplying ? 'Applying...' : 'Apply settings'}
</Button>
</div>
</div>
{:else if settingsChanges.hasChanges && !workspaceChanges.hasChanges && !settingsApplied}
<!-- Only settings changes -->
<div class="flex gap-2">
<Button
size="md"
onclick={applySettingsOnly}
disabled={isApplying}
startIcon={{
icon: isApplying ? Loader2 : Save,
classes: isApplying ? 'animate-spin' : ''
}}
>
{isApplying ? 'Applying...' : 'Apply settings'}
</Button>
</div>
{:else if workspaceChanges.hasChanges && (!settingsChanges.hasChanges || settingsApplied)}
<!-- Step 2: Workspace changes (either no settings changes, or settings already applied) -->
<div class="flex flex-col gap-3">
{#if settingsApplied}
<div class="text-sm font-medium text-primary">Step 2 of 2: Pull Workspace Changes</div>
<div class="text-xs text-green-600">✓ Settings applied successfully. Now you can pull the workspace changes.</div>
{/if}
<div class="flex gap-2">
<Button
size="md"
onclick={() => executeJob(false, false)}
disabled={isApplying}
startIcon={{
icon: isApplying ? Loader2 : Save,
classes: isApplying ? 'animate-spin' : ''
}}
>
{isApplying ? 'Pulling...' : 'Pull from repository'}
</Button>
</div>
</div>
{:else}
<!-- No changes to pull -->
<div class="bg-surface-secondary rounded-lg p-3">
<div class="text-sm text-tertiary">No changes to pull from the repository.</div>
</div>
{/if}
</div>
{/if}
{/if}
<!-- Job status for apply -->
{#if applyJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if applyJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if applyJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if applyJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Pull job:
<a
target="_blank"
class="underline"
href={`/run/${applyJobId}?workspace=${$workspaceStore}`}
>
{applyJobId}
</a>
</div>
{/if}
<!-- Apply error -->
{#if applyError}
<Alert type="error" title="Pull failed">
{applyError}
</Alert>
{/if}
<!-- CLI Instructions (collapsible) -->
<div class="border-t pt-4 mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => showCliInstructions = !showCliInstructions}
>
<Terminal size={16} />
<span>CLI Instructions</span>
{#if showCliInstructions}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if showCliInstructions}
<div class="mt-3 bg-surface-secondary rounded-lg p-3">
<pre class="text-xs bg-surface p-3 rounded overflow-x-auto whitespace-pre-wrap break-all">
# Setup (only needed if local folder not initialized yet)
npm install -g windmill-cli
wmill workspace add {$workspaceStore} {$workspaceStore} {window.location.origin}
wmill init --workspace {$workspaceStore} --repository {gitRepoResourcePath}
{#if !settingsOnly}
# Push from git repository to workspace
wmill sync push --workspace {$workspaceStore} --repository {gitRepoResourcePath}
{/if}
# Push settings only from git repository
wmill gitsync-settings push --workspace {$workspaceStore} --repository {gitRepoResourcePath}</pre>
</div>
{/if}
</div>
</div>
</Modal>
@@ -0,0 +1,344 @@
<script lang="ts">
import Modal from '$lib/components/common/modal/Modal.svelte'
import { Button, Alert } from '$lib/components/common'
import { Loader2, CheckCircle2, XCircle, Terminal, ChevronDown, ChevronUp } from 'lucide-svelte'
import GitDiffPreview from '../GitDiffPreview.svelte'
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
import { tryEvery } from '$lib/utils'
import type { SyncResponse, SettingsObject } from '$lib/git-sync'
interface Props {
open: boolean
gitRepoResourcePath: string
uiState: SettingsObject
onSuccess?: () => void
isNewConnection?: boolean
onSaveWithoutInit?: () => void
}
let {
open = $bindable(false),
gitRepoResourcePath,
uiState,
onSuccess,
isNewConnection = false,
onSaveWithoutInit
}: Props = $props()
// Job state
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isPreviewLoading = $state(false)
let previewError = $state('')
let applyJobId = $state<string | null>(null)
let applyJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isApplying = $state(false)
let applyError = $state('')
// UI state
let showCliInstructions = $state(false)
let previewResult = $state<SyncResponse | null>(null)
// Note: Escape key is handled by the Modal component itself
// Reset state when modal opens/closes
$effect(() => {
if (!open) {
previewJobId = null
previewJobStatus = undefined
isPreviewLoading = false
previewError = ''
applyJobId = null
applyJobStatus = undefined
isApplying = false
applyError = ''
showCliInstructions = false
previewResult = null
}
})
// Execute job with dry run or actual execution
async function executeJob(isDryRun: boolean) {
const isPreview = isDryRun
if (isPreview) {
isPreviewLoading = true
previewError = ''
previewResult = null
previewJobId = null
previewJobStatus = undefined
} else {
isApplying = true
applyError = ''
applyJobId = null
applyJobStatus = undefined
}
try {
const workspace = $workspaceStore
if (!workspace) return
const payload = {
workspace_id: workspace,
repo_url_resource_path: gitRepoResourcePath,
dry_run: isDryRun,
pull: false,
settings_json: JSON.stringify(uiState)
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payload,
skipPreprocessor: true
})
if (isPreview) {
previewJobId = jobId
previewJobStatus = 'running'
} else {
applyJobId = jobId
applyJobStatus = 'running'
}
let jobSuccess = false
let result: any = {}
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
jobSuccess = !!testResult.success
if (jobSuccess) {
const jobResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
result = jobResult
}
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: `${isPreview ? 'Preview' : 'Apply'} job timed out after 60s` }
})
} catch (err) {}
},
interval: 500,
timeout: 60000
})
if (isPreview) {
previewJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
previewResult = result
} else {
previewError = 'Preview failed'
}
} else {
applyJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
onSuccess?.()
} else {
applyError = 'Push failed'
}
}
} catch (e) {
const errorMsg = e?.message || 'Operation failed'
if (isPreview) {
previewJobStatus = 'failure'
previewError = errorMsg
} else {
applyJobStatus = 'failure'
applyError = errorMsg
}
} finally {
if (isPreview) {
isPreviewLoading = false
} else {
isApplying = false
}
}
}
</script>
<Modal bind:open title="Push Workspace to Git Repository" class="sm:max-w-4xl">
<div class="flex flex-col gap-4">
<!-- Description -->
<p class="text-sm text-secondary">Push your current workspace content to the connected Git repository based on the configured filters.</p>
<p class="text-sm text-tertiary">Note: This will not update git sync settings in wmill.yaml. Settings can only be pulled from the repository as it is the source of truth.</p>
<!-- Settings display for new connections -->
{#if isNewConnection}
<div class="bg-surface-secondary border border-border rounded-lg p-3">
<h4 class="text-sm font-medium text-primary mb-2">Settings that will be pushed to repository</h4>
<div class="text-xs text-secondary space-y-1">
<div><strong>Include paths:</strong> {uiState.include_path?.join(', ') || 'None'}</div>
<div><strong>Exclude paths:</strong> {uiState.exclude_path?.join(', ') || 'None'}</div>
{#if uiState.extra_include_path?.length > 0}
<div><strong>Extra include paths:</strong> {uiState.extra_include_path.join(', ')}</div>
{/if}
<div><strong>Include types:</strong> {uiState.include_type?.join(', ') || 'None'}</div>
</div>
<p class="text-xs text-tertiary mt-2">To modify these settings, cancel and configure them in the workspace settings.</p>
</div>
{/if}
<!-- Preview section -->
{#if !previewResult}
<div class="flex justify-start pt-4">
<Button
size="md"
color="dark"
onclick={() => executeJob(true)}
disabled={isPreviewLoading}
startIcon={{
icon: isPreviewLoading ? Loader2 : undefined,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
>
{isPreviewLoading ? 'Previewing...' : 'Preview changes'}
</Button>
</div>
{/if}
<!-- Job status for preview -->
{#if previewJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if previewJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if previewJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if previewJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Preview job:
<a
target="_blank"
class="underline"
href={`/run/${previewJobId}?workspace=${$workspaceStore}`}
>
{previewJobId}
</a>
</div>
{/if}
<!-- Preview error -->
{#if previewError}
<Alert type="error" title="Preview failed">
{previewError}
</Alert>
{/if}
<!-- Preview results -->
{#if previewResult && !previewError}
<div class="space-y-3">
<h4 class="text-sm font-semibold text-primary">Changes to Push</h4>
{#if previewResult.changes?.length > 0}
<GitDiffPreview previewResult={previewResult} />
{:else}
<div class="bg-surface-secondary rounded-lg p-3">
<div class="text-sm text-tertiary">No changes to push to the repository.</div>
</div>
{/if}
</div>
{/if}
<!-- Apply section (shown after successful preview) -->
{#if previewResult && !previewError}
{@const hasChanges = previewResult.changes?.length > 0}
{#if hasChanges}
<div class="border-t pt-4 mt-4">
<div class="flex justify-start gap-2">
<Button
size="xs"
onclick={() => executeJob(false)}
disabled={isApplying}
startIcon={{
icon: isApplying ? Loader2 : undefined,
classes: isApplying ? 'animate-spin' : ''
}}
>
{isApplying ?
(isNewConnection ? 'Initializing...' : 'Pushing...') :
(isNewConnection ? 'Initialize repo and save connection' : 'Push to repository')
}
</Button>
{#if isNewConnection && onSaveWithoutInit}
<Button
size="xs"
color="light"
onclick={onSaveWithoutInit}
disabled={isApplying}
>
Save without initializing repo
</Button>
{/if}
</div>
</div>
{/if}
{/if}
<!-- Job status for apply -->
{#if applyJobId}
<div class="flex items-center gap-2 text-xs text-tertiary">
{#if applyJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if applyJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if applyJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Push job:
<a
target="_blank"
class="underline"
href={`/run/${applyJobId}?workspace=${$workspaceStore}`}
>
{applyJobId}
</a>
</div>
{/if}
<!-- Apply error -->
{#if applyError}
<Alert type="error" title="Push failed">
{applyError}
</Alert>
{/if}
<!-- CLI Instructions (collapsible) -->
<div class="border-t pt-4 mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => showCliInstructions = !showCliInstructions}
>
<Terminal size={16} />
<span>CLI Instructions</span>
{#if showCliInstructions}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if showCliInstructions}
<div class="mt-3 bg-surface-secondary rounded-lg p-3">
<pre class="text-xs bg-surface p-3 rounded overflow-x-auto whitespace-pre-wrap break-all">
# Setup (only needed if local folder not initialized yet)
npm install -g windmill-cli
wmill workspace add {$workspaceStore} {$workspaceStore} {window.location.origin}
wmill init --workspace {$workspaceStore} --repository {gitRepoResourcePath}
# Pull workspace content to git repository
wmill sync pull --workspace {$workspaceStore} --repository {gitRepoResourcePath}</pre>
</div>
{/if}
</div>
</div>
</Modal>
@@ -1,31 +1,11 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import { Filter, Save, Eye, Loader2, CheckCircle2, XCircle, Check } from 'lucide-svelte'
import { Filter, Terminal, ChevronDown, ChevronUp } from 'lucide-svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import yaml from 'js-yaml'
import hubPaths from '$lib/hubPaths.json'
import { JobService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Button } from '$lib/components/common'
import { sendUserToast } from '$lib/toast'
import FilterList from './FilterList.svelte'
import { Tabs, Tab } from '$lib/components/common'
type ObjectType =
| 'script'
| 'flow'
| 'app'
| 'folder'
| 'resource'
| 'variable'
| 'secret'
| 'resourcetype'
| 'schedule'
| 'user'
| 'group'
| 'trigger'
| 'settings'
| 'key'
import type { GitSyncObjectType } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
type GitSyncTypeMap = {
scripts: boolean
@@ -44,53 +24,25 @@
key: boolean
}
type PreviewResult = {
diff?: { [key: string]: { from: any; to: any } }
hasChanges?: boolean
isInitialSetup?: boolean
message?: string
local?: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: ObjectType[]
}
backend?: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: ObjectType[]
}
}
let {
git_repo_resource_path = $bindable(''),
include_path = $bindable(['f/**']),
include_type = $bindable(['script', 'flow', 'app', 'folder'] as ObjectType[]),
exclude_types_override = $bindable([] as ObjectType[]),
include_type = $bindable(['script', 'flow', 'app', 'folder'] as GitSyncObjectType[]),
exclude_types_override = $bindable([] as GitSyncObjectType[]),
isLegacyRepo = false,
yamlText = $bindable(''),
onSettingsChange = (settings: { yaml: string }) => {},
excludes = $bindable([] as string[]),
extraIncludes = $bindable([] as string[])
extraIncludes = $bindable([] as string[]),
isInitialSetup = false,
requiresMigration = false,
actions = undefined
} = $props()
// Component state
let collapsed = $state(false)
let editAsYaml = $state(false)
let yamlError = $state('')
let isPullMode = $state(false)
let showCliInstructions = $state(false)
// Preview/Push state
let previewResult = $state<PreviewResult | null>(null)
let previewJobId = $state<string | null>(null)
let previewJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let pushJobId = $state<string | null>(null)
let pushJobStatus = $state<'running' | 'success' | 'failure' | undefined>(undefined)
let isPreviewLoading = $state(false)
let isPushing = $state(false)
let previewError = $state('')
let previewSettingsSnapshot = $state<string | null>(null)
// Determine if component should be editable or read-only
const isEditable = $derived(isInitialSetup || requiresMigration)
// Compute effective include types (include_type minus exclude_types_override for legacy repos only)
const effectiveIncludeTypes = $derived(
@@ -122,7 +74,7 @@
function updateIncludeType(key: keyof GitSyncTypeMap, value: boolean) {
const newTypes = new Set(include_type)
const typeMap: Record<keyof GitSyncTypeMap, ObjectType> = {
const typeMap: Record<keyof GitSyncTypeMap, GitSyncObjectType> = {
scripts: 'script',
flows: 'flow',
apps: 'app',
@@ -155,369 +107,7 @@
return str.charAt(0).toUpperCase() + str.slice(1)
}
// Simple JSON-based UI state helper
function getUIState() {
return {
include_path,
exclude_path: excludes,
extra_include_path: extraIncludes,
include_type
}
}
// Apply settings from backend format (used by both local git repo and backend settings)
function fromBackendFormat(settings: {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: ObjectType[]
}) {
include_path = settings.include_path || []
excludes = settings.exclude_path || []
extraIncludes = settings.extra_include_path || []
include_type = settings.include_type || []
}
// Simplified YAML parsing for manual editing
function fromYaml(yamlStr: string) {
yamlError = ''
try {
const parsed = yaml.load(yamlStr)
if (!parsed || typeof parsed !== 'object') {
throw new Error('Invalid YAML structure')
}
const obj: any = parsed
yamlText = yamlStr
// Extract includes - reset to default if not present
if (obj.includes && Array.isArray(obj.includes)) {
include_path = obj.includes.map((p: any) => {
if (typeof p !== 'string') {
throw new Error('includes must contain only strings')
}
// Handle quoted strings
if (/^['"].*['"]$/.test(p)) {
return p.slice(1, -1).replace(/''/g, "'")
}
return p
})
} else {
// Reset to default if includes is not present
include_path = ['f/**']
}
// Build the type set based on the YAML flags
const newTypes = new Set<ObjectType>()
// Always include core types (these are fundamental and not controlled by flags)
newTypes.add('script')
newTypes.add('flow')
newTypes.add('app')
newTypes.add('folder')
// Handle skip flags (if skipX is false or undefined, include the type)
if (obj.skipResourceTypes !== true) newTypes.add('resourcetype')
if (obj.skipResources !== true) newTypes.add('resource')
if (obj.skipVariables !== true) newTypes.add('variable')
if (obj.skipSecrets !== true) newTypes.add('secret')
// Handle include flags (if includeX is true, include the type)
if (obj.includeSchedules === true) newTypes.add('schedule')
if (obj.includeTriggers === true) newTypes.add('trigger')
if (obj.includeUsers === true) newTypes.add('user')
if (obj.includeGroups === true) newTypes.add('group')
if (obj.includeSettings === true) newTypes.add('settings')
if (obj.includeKey === true) newTypes.add('key')
// Apply business rule: secrets can only be included if variables are included
// This matches the UI behavior where turning off variables also turns off secrets
if (!newTypes.has('variable')) {
newTypes.delete('secret')
}
include_type = Array.from(newTypes)
} catch (e) {
yamlError = e.message || 'Invalid YAML'
console.error('Error parsing YAML:', e)
}
}
// Simple YAML generation for manual editing mode
function generateYamlFromUI() {
try {
const validIncludePath = include_path
const validExcludePath = excludes
const validExtraInclude = extraIncludes
// Basic YAML structure - let the CLI handle the proper normalization
let config: any = {
includes: validIncludePath,
excludes: validExcludePath,
extraIncludes: validExtraInclude,
codebases: []
}
// Let the CLI handle the optimization of skip/include flags
// Just convert the UI state directly
if (!include_type.includes('variable')) config.skipVariables = true
if (!include_type.includes('resource')) config.skipResources = true
if (!include_type.includes('secret')) config.skipSecrets = true
if (!include_type.includes('resourcetype')) config.skipResourceTypes = true
if (include_type.includes('schedule')) config.includeSchedules = true
if (include_type.includes('trigger')) config.includeTriggers = true
if (include_type.includes('user')) config.includeUsers = true
if (include_type.includes('group')) config.includeGroups = true
if (include_type.includes('settings')) config.includeSettings = true
if (include_type.includes('key')) config.includeKey = true
return yaml.dump(config, {
indent: 2,
lineWidth: -1,
quotingType: '"',
forceQuotes: false,
noRefs: true
})
} catch (e) {
console.warn('Failed to generate YAML:', e)
yamlError = e.message || 'Failed to generate YAML'
return `includes:
- f/**
excludes: []
extraIncludes: []
codebases: []`
}
}
function switchToYaml() {
yamlText = generateYamlFromUI()
yamlError = ''
editAsYaml = true
}
function switchToUI() {
fromYaml(yamlText)
if (!yamlError) {
editAsYaml = false
}
}
// Simplified preview function - always uses JSON approach
async function previewFiltersToGitRepo() {
isPreviewLoading = true
previewError = ''
previewResult = null
previewJobId = null
previewJobStatus = undefined
// Take a snapshot of current settings
previewSettingsSnapshot = JSON.stringify({
include_path,
excludes,
extraIncludes,
include_type
})
try {
const workspace = $workspaceStore
if (!workspace) return
// Always pass UI state as JSON - the backend now handles this uniformly
const payloadObj = {
workspace_id: workspace,
repo_url_resource_path: git_repo_resource_path,
only_wmill_yaml: true,
dry_run: true,
pull: isPullMode,
settings_json: JSON.stringify(getUIState())
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payloadObj,
skipPreprocessor: true
})
previewJobId = jobId
previewJobStatus = 'running'
let jobSuccess = false
let result: PreviewResult = {}
await (
await import('$lib/utils')
).tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
jobSuccess = !!testResult.success
if (jobSuccess) {
const jobResult = await JobService.getCompletedJobResult({ workspace, id: jobId })
result = jobResult as PreviewResult
}
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: 'Preview job timed out after 5s' }
})
} catch (err) {}
},
interval: 500,
timeout: 10000
})
previewJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
previewResult = result
} else {
previewError = 'Preview failed'
}
} catch (e) {
previewJobStatus = 'failure'
previewError = e?.message || 'Preview failed'
previewResult = null
} finally {
isPreviewLoading = false
}
}
// Simplified push function - always uses JSON approach
async function pushFiltersToGitRepo() {
if (isPullMode) {
// In pull mode, apply the local settings (from git repo) to UI
if (previewResult?.local) {
try {
fromBackendFormat(previewResult.local)
yamlText = generateYamlFromUI()
onSettingsChange({ yaml: yamlText })
sendUserToast('Changes applied - remember to save repository settings to persist changes')
// Clear the preview state after applying settings
previewResult = null
previewJobId = null
previewJobStatus = undefined
previewError = ''
} catch (e) {
previewError = 'Failed to apply pulled settings: ' + e.message
}
}
return
}
// Push mode - send current UI state as JSON
isPushing = true
pushJobId = null
pushJobStatus = undefined
try {
const workspace = $workspaceStore
if (!workspace) return
const payloadObj = {
workspace_id: workspace,
repo_url_resource_path: git_repo_resource_path,
dry_run: false,
pull: isPullMode,
only_wmill_yaml: true,
settings_json: JSON.stringify(getUIState())
}
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.gitInitRepo,
requestBody: payloadObj,
skipPreprocessor: true
})
pushJobId = jobId
pushJobStatus = 'running'
let jobSuccess = false
await (
await import('$lib/utils')
).tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
jobSuccess = !!testResult.success
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: 'Push job timed out after 5s' }
})
} catch (err) {}
},
interval: 500,
timeout: 10000
})
pushJobStatus = jobSuccess ? 'success' : 'failure'
if (jobSuccess) {
// Reset preview state after successful push
previewResult = null
previewJobId = null
previewJobStatus = undefined
previewError = ''
}
} catch (e) {
pushJobStatus = 'failure'
} finally {
isPushing = false
}
}
// Simplified export function for backward compatibility
export function toYaml() {
return generateYamlFromUI()
}
export function setSettings(settings: { yaml: string }) {
yamlText = settings.yaml
fromYaml(settings.yaml)
}
$effect(() => {
// Reset preview state when switching modes
if (isPullMode !== undefined) {
previewResult = null
previewJobId = null
previewJobStatus = undefined
pushJobId = null
pushJobStatus = undefined
isPreviewLoading = false
isPushing = false
previewError = ''
}
})
// Reset preview state when settings change (making preview stale)
$effect(() => {
// Track all the settings that affect the preview
const currentSettings = JSON.stringify({
include_path,
excludes,
extraIncludes,
include_type
})
// If we have an existing preview result and settings have changed from snapshot, clear it
if (
previewResult !== null &&
previewSettingsSnapshot !== null &&
currentSettings !== previewSettingsSnapshot
) {
previewResult = null
previewJobId = null
previewJobStatus = undefined
previewError = ''
previewSettingsSnapshot = null
}
})
</script>
<div class="rounded-lg shadow-sm border p-0 w-full">
@@ -526,18 +116,19 @@ codebases: []`
<div class="flex items-center gap-2">
<Filter size={18} class="text-primary" />
<span class="font-semibold text-sm">Git Sync filter settings</span>
{#if isLegacyRepo}
<Tooltip>
This repository uses legacy configuration format and inherits settings from workspace-level defaults. Excluded types are filtered out from inherited types. Save to migrate to the new format.
</Tooltip>
{:else if !isEditable}
<Tooltip documentationLink="https://www.windmill.dev/docs/advanced/cli/sync#wmillyaml">
These settings are controlled by the wmill.yaml file in your git repository. Click "Pull from repo" to check for settings drift and pull settings from repo.
</Tooltip>
{/if}
</div>
<div class="flex items-center gap-2">
{#if !collapsed}
<button
class="text-xs px-2 py-1 rounded border border-gray-300 bg-surface-primary hover:bg-surface-secondary"
onclick={editAsYaml ? switchToUI : switchToYaml}
>
{editAsYaml ? 'Edit in UI' : 'Edit as YAML'}
</button>
{/if}
<button
class="text-gray-500 hover:text-primary focus:outline-none"
class="text-secondary hover:text-primary focus:outline-none"
onclick={() => (collapsed = !collapsed)}
aria-label="Toggle collapse"
>
@@ -576,18 +167,8 @@ codebases: []`
</div>
</div>
{#if !collapsed}
{#if editAsYaml}
<div class="px-4 py-4">
<textarea
class="w-full h-64 font-mono text-xs border rounded p-2 bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary"
spellcheck="false"
bind:value={yamlText}
></textarea>
{#if yamlError}
<div class="text-xs text-red-600 mt-2">{yamlError}</div>
{/if}
</div>
{:else}
{#if isEditable}
<!-- Editable mode -->
<div class="px-4 py-2">
<div class="grid grid-cols-1 md:grid-cols-2 md:gap-32">
<div class="flex flex-col gap-2">
@@ -753,124 +334,105 @@ codebases: []`
</div>
</div>
</div>
<div class="mt-6 flex flex-col gap-2 p-2">
<div class="flex flex-col gap-2 mb-2">
<Toggle
size="sm"
bind:checked={isPullMode}
options={{
left: 'Push',
right: 'Pull'
}}
/>
<span class="text-xs text-tertiary">
{isPullMode ? 'Pull settings from Git repository' : 'Push settings to Git repository'}
</span>
<div class="mt-6 p-2 border-t">
<div class="text-xs text-tertiary mb-2">
{isInitialSetup ? 'Configure initial sync settings' : 'Review migration settings'}
</div>
<div class="flex gap-2 items-center">
<Button
size="sm"
on:click={previewFiltersToGitRepo}
disabled={isPreviewLoading || isPushing}
startIcon={{
icon: isPreviewLoading ? Loader2 : Eye,
classes: isPreviewLoading ? 'animate-spin' : ''
}}
</div>
{:else}
<!-- Read-only view -->
<div class="px-4 py-2">
<div class="grid grid-cols-1 md:grid-cols-2 md:gap-8">
<div class="flex flex-col gap-3">
<div>
<h4 class="font-semibold text-sm mb-1">Include Paths</h4>
{#if include_path.length > 0}
<div class="flex flex-wrap gap-1 text-xs">
{#each include_path as path}
<span class="bg-surface-secondary text-primary rounded-full px-2 py-1">{path}</span>
{/each}
</div>
{:else}
<div class="text-tertiary text-xs">No include paths configured</div>
{/if}
</div>
<div>
<h4 class="font-semibold text-sm mb-1">Exclude Paths</h4>
{#if excludes.length > 0}
<div class="flex flex-wrap gap-1 text-xs">
{#each excludes as path}
<span class="bg-red-100 text-red-800 rounded-full px-2 py-1">{path}</span>
{/each}
</div>
{:else}
<div class="text-tertiary text-xs">No exclude paths configured</div>
{/if}
</div>
</div>
<div class="flex flex-col gap-2">
<h4 class="font-semibold text-sm">Included Types</h4>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
{#each Object.entries(typeToggles) as [key, enabled]}
<div class="flex items-center gap-1">
<div class={enabled ? 'text-green-600' : 'text-gray-400'}>
{enabled ? '✓' : '✗'}
</div>
<span class={enabled ? 'text-primary' : 'text-tertiary'}>
{capitalize(key)}
</span>
</div>
{/each}
</div>
</div>
</div>
<!-- Actions slot for custom buttons -->
{#if actions}
<div class="flex justify-start mt-4">
{@render actions()}
</div>
{/if}
<!-- CLI Instructions (collapsible) -->
<div class="border-t pt-2 mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => showCliInstructions = !showCliInstructions}
>
{isPreviewLoading ? 'Previewing...' : 'Preview'}
</Button>
{#if previewResult?.hasChanges && (previewResult?.isInitialSetup || (previewResult?.diff && Object.keys(previewResult.diff).length > 0))}
<Button
size="sm"
on:click={pushFiltersToGitRepo}
disabled={isPushing || isPreviewLoading}
color={isPullMode ? 'dark' : 'red'}
startIcon={{
icon: isPushing ? Loader2 : isPullMode ? Check : Save,
classes: isPushing ? 'animate-spin' : ''
}}
>
{isPushing
? isPullMode
? 'Applying...'
: 'Pushing...'
: isPullMode
? 'Apply'
: 'Push Settings to Git'}
</Button>
<Terminal size={16} />
<span>Update settings with CLI</span>
{#if showCliInstructions}
<ChevronUp size={16} />
{:else}
<ChevronDown size={16} />
{/if}
</button>
{#if showCliInstructions}
<div class="mt-3 bg-surface-secondary rounded-lg p-3">
<div class="text-xs text-tertiary mb-2">
These filter settings are sourced from the <code class="bg-surface px-1 py-0.5 rounded">wmill.yaml</code> file in your git repository.
To modify them, edit the file in your repository, commit the changes, and sync using these commands:
</div>
<pre class="text-xs bg-surface p-3 rounded overflow-x-auto whitespace-pre-wrap break-all">
# Make sure your repo is up to date
git pull
# Edit wmill.yaml file
vim wmill.yaml
# Push changes to workspace
wmill gitsync-settings push --workspace {$workspaceStore} --repository {git_repo_resource_path}
# Commit changes
git add wmill.yaml
git commit
git push</pre>
</div>
{/if}
</div>
{#if previewError}
<div class="text-xs text-red-600 mt-2">{previewError}</div>
{/if}
{#if previewJobId}
<div class="flex items-center gap-2 text-xs text-tertiary mt-1">
{#if previewJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if previewJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if previewJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Preview job:
<a
target="_blank"
class="underline"
href={`/run/${previewJobId}?workspace=${$workspaceStore}`}>{previewJobId}</a
>
</div>
{/if}
{#if previewResult}
<div
class="border rounded p-2 text-xs max-h-40 overflow-y-auto bg-surface-secondary mt-2"
>
<div class="font-semibold text-[11px] mb-1 text-tertiary">Preview of changes:</div>
{#if previewResult.isInitialSetup}
<div class="mt-2 text-green-600">
{previewResult.message || 'wmill.yaml will be created with repository settings'}
</div>
{:else if previewResult.hasChanges && previewResult.diff && Object.keys(previewResult.diff).length > 0}
<div class="mt-2 space-y-1">
{#each Object.entries(previewResult.diff) as [field, change]}
<div class="flex items-start gap-2 text-2xs">
<span class="font-mono text-tertiary min-w-0 flex-shrink-0">{field}:</span>
<div class="min-w-0 flex-1">
{#if Array.isArray(change.from) || Array.isArray(change.to)}
<div class="space-y-0.5">
<div class="text-red-600">- {JSON.stringify(change.from)}</div>
<div class="text-green-600">+ {JSON.stringify(change.to)}</div>
</div>
{:else}
<span class="text-red-600">{JSON.stringify(change.from)}</span>
<span class="text-tertiary"></span>
<span class="text-green-600">{JSON.stringify(change.to)}</span>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<div class="mt-2 text-tertiary">No changes found! The file is up to date.</div>
{/if}
</div>
{/if}
{#if pushJobId}
<div class="flex items-center gap-2 text-xs text-tertiary mt-1">
{#if pushJobStatus === 'running'}
<Loader2 class="animate-spin" size={14} />
{:else if pushJobStatus === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else if pushJobStatus === 'failure'}
<XCircle size={14} class="text-red-700" />
{/if}
Push job:
<a
target="_blank"
class="underline"
href={`/run/${pushJobId}?workspace=${$workspaceStore}`}>{pushJobId}</a
>
</div>
{/if}
</div>
{/if}
{/if}
+34
View File
@@ -0,0 +1,34 @@
// Deterministic backend response formats from hub script and CLI
export interface SyncResponse {
success: true
changes: Array<{
type: 'added' | 'edited' | 'deleted'
path: string
codebase_changed?: boolean
}>
total: number
settingsDiffResult?: {
hasChanges: boolean
diff: Record<string, { from: any; to: any }>
local: SettingsObject
backend: SettingsObject
}
}
export interface SettingsResponse {
success: true
hasChanges: boolean
local: SettingsObject
backend: SettingsObject
diff: Record<string, { from: any; to: any }>
repository: string
}
import type { GitSyncObjectType } from '$lib/gen'
export interface SettingsObject {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: GitSyncObjectType[]
}
+3 -2
View File
@@ -8,12 +8,13 @@
"gitSync_6": "hub/11666/sync-script-to-git-repo-windmill",
"gitSync_7": "hub/11668/sync-script-to-git-repo-windmill",
"gitSync_8": "hub/19673/sync-script-to-git-repo-windmill",
"gitSync": "hub/19738/sync-script-to-git-repo-windmill",
"gitSync_9": "hub/19738/sync-script-to-git-repo-windmill",
"gitSync": "hub/19785/sync-script-to-git-repo-windmill",
"gitSyncTest_0": "hub/9073/git-repo-test-read-write-windmill",
"gitSyncTest_1": "hub/11499/git-repo-test-read-write-windmill",
"gitSyncTest_2": "hub/11667/git-repo-test-read-write-windmill",
"gitSyncTest": "hub/11669/git-repo-test-read-write-windmill",
"gitInitRepo": "hub/19740/git-sync%3A-init-repository-windmill",
"gitInitRepo": "hub/19784/git-sync%3A-init-repository-windmill",
"slackErrorHandler": "hub/19741/workspace-or-schedule-error-handler-slack",
"slackErrorHandler_0": "hub/9079/workspace-or-schedule-error-handler-slack",
"slackErrorHandler_1": "hub/9206/workspace-or-schedule-error-handler-slack",
+165
View File
@@ -0,0 +1,165 @@
import { JobService } from '$lib/gen'
import { tryEvery } from '$lib/utils'
export interface JobStatus {
status: 'running' | 'success' | 'failure'
result?: any
error?: string
}
export interface JobOptions {
onProgress?: (status: JobStatus) => void
timeout?: number
workspace: string
interval?: number
timeoutMessage?: string
}
interface JobEntry {
controller: AbortController
startTime: number
}
export class JobManager {
private activeJobs = new Map<string, JobEntry>()
private cleanupInterval: NodeJS.Timeout | null = null
private readonly STALE_TIMEOUT = 300000 // 5 minutes
private readonly CLEANUP_INTERVAL = 60000 // 1 minute
constructor() {
this.startCleanupTimer()
}
private startCleanupTimer() {
if (typeof window !== 'undefined') {
this.cleanupInterval = setInterval(() => {
this.cleanup()
}, this.CLEANUP_INTERVAL)
}
}
private cleanup() {
const now = Date.now()
const staleJobs: string[] = []
for (const [jobId, entry] of this.activeJobs.entries()) {
if (now - entry.startTime > this.STALE_TIMEOUT) {
entry.controller.abort()
staleJobs.push(jobId)
}
}
staleJobs.forEach(jobId => {
this.activeJobs.delete(jobId)
})
if (staleJobs.length > 0) {
console.warn(`Cleaned up ${staleJobs.length} stale job controllers`)
}
}
async runWithProgress<T>(
jobRunner: () => Promise<string>,
options: JobOptions
): Promise<T> {
const {
onProgress,
timeout = 60000,
workspace,
interval = 500,
timeoutMessage = `Job timed out after ${timeout / 1000}s`
} = options
const controller = new AbortController()
const jobId = await jobRunner()
this.activeJobs.set(jobId, {
controller,
startTime: Date.now()
})
try {
onProgress?.({ status: 'running' })
const result = await tryEvery({
tryCode: async () => {
if (controller.signal.aborted) {
throw new Error('Job was cancelled')
}
const jobResult = await JobService.getCompletedJob({
workspace,
id: jobId
})
const success = !!jobResult.success
const status: JobStatus = {
status: success ? 'success' : 'failure',
result: jobResult.result,
error: success ? undefined : (jobResult.result as any)?.error?.message || 'Job failed'
}
onProgress?.(status)
if (!success) {
throw new Error(status.error)
}
return jobResult.result as T
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: timeoutMessage }
})
} catch (err) {
console.error('Failed to cancel job:', err)
}
onProgress?.({ status: 'failure', error: timeoutMessage })
throw new Error(timeoutMessage)
},
interval,
timeout
})
return result as T
} finally {
this.activeJobs.delete(jobId)
}
}
cancel(jobId: string) {
const entry = this.activeJobs.get(jobId)
if (entry) {
entry.controller.abort()
this.activeJobs.delete(jobId)
}
}
cancelAll() {
this.activeJobs.forEach(entry => entry.controller.abort())
this.activeJobs.clear()
}
isActive(jobId: string): boolean {
return this.activeJobs.has(jobId)
}
get activeJobCount(): number {
return this.activeJobs.size
}
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
this.cancelAll()
}
}
// Singleton instance for global usage
export const jobManager = new JobManager()
@@ -4,12 +4,10 @@
import { isCloudHosted } from '$lib/cloud'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Alert, Button, Skeleton, Tab, Tabs } from '$lib/components/common'
import { deepEqual } from 'fast-equals'
import DeployToSetting from '$lib/components/DeployToSetting.svelte'
import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
@@ -18,7 +16,6 @@
import {
OauthService,
WorkspaceService,
JobService,
ResourceService,
SettingService,
type AIConfig
@@ -32,23 +29,15 @@
isCriticalAlertsUIOpen
} from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { emptyString, tryEvery } from '$lib/utils'
import { emptyString } from '$lib/utils'
import {
XCircle,
RotateCw,
RotateCcw,
CheckCircle2,
Trash,
Plus,
Loader2,
Save,
ExternalLink
Save
} from 'lucide-svelte'
import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import { fade } from 'svelte/transition'
import ChangeWorkspaceName from '$lib/components/settings/ChangeWorkspaceName.svelte'
import ChangeWorkspaceId from '$lib/components/settings/ChangeWorkspaceId.svelte'
import ChangeWorkspaceColor from '$lib/components/settings/ChangeWorkspaceColor.svelte'
@@ -57,61 +46,13 @@
type S3ResourceSettings
} from '$lib/workspace_settings'
import { base } from '$lib/base'
import { hubPaths } from '$lib/hub'
import Description from '$lib/components/Description.svelte'
import ConnectionSection from '$lib/components/ConnectionSection.svelte'
import AISettings from '$lib/components/workspaceSettings/AISettings.svelte'
import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte'
import InitGitRepoPopover from '$lib/components/InitGitRepoPopover.svelte'
import PullGitRepoPopover from '$lib/components/PullGitRepoPopover.svelte'
import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte'
import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte'
import { untrack } from 'svelte'
// Shared defaults for new Git-Sync repositories
const DEFAULT_INCLUDE_PATH = ['f/**'] as const;
const DEFAULT_EXCLUDE_PATH: string[] = [];
const DEFAULT_EXTRA_INCLUDE_PATH: string[] = [];
type ObjectType =
| 'script'
| 'flow'
| 'app'
| 'folder'
| 'resource'
| 'variable'
| 'secret'
| 'resourcetype'
| 'schedule'
| 'user'
| 'group'
| 'trigger'
| 'settings'
| 'key'
type GitSyncSettings = {
repositories: GitSyncRepository[]
}
type GitRepositorySettings = {
include_path: string[]
exclude_path: string[]
extra_include_path: string[]
include_type: ObjectType[]
}
// Import the generated backend type
import type { GitRepositorySettings as BackendGitRepositorySettings } from '$lib/gen'
// Frontend repository format extends backend with guaranteed settings and additional UI state
type GitSyncRepository = BackendGitRepositorySettings & {
settings: GitRepositorySettings // Required in frontend after transformation
legacyImported?: boolean
}
// Workspace-level legacy filter arrays (populated if we imported legacy settings)
let legacyWorkspaceIncludePath = $state<string[]>([])
let legacyWorkspaceIncludeType = $state<ObjectType[]>([])
let slackInitialPath: string = $state('')
let slackScriptPath: string = $state('')
let teamsInitialPath: string = $state('')
@@ -143,16 +84,6 @@
secondaryStorage: undefined
})
let gitSyncSettings = $state<GitSyncSettings>({
repositories: []
})
let gitSyncTestJobs = $state<
{
jobId: string | undefined
status: 'running' | 'success' | 'failure' | undefined
}[]
>([])
let workspaceDefaultAppPath: string | undefined = $state(undefined)
let workspaceEncryptionKey: string | undefined = $state(undefined)
@@ -172,245 +103,14 @@
)
let usingOpenaiClientCredentialsOauth = $state(false)
let yamlText = $state('')
let initialGitSyncSettings = $state<GitSyncSettings | undefined>(undefined)
let loadedSettings = $state(false)
// Reactive trigger to ensure UI updates when repository data changes
let repoReactivityTrigger = $state(0)
const latestGitSyncHubScript = hubPaths.gitSync
// Each repository may have been populated from workspace-level legacy settings. Track on the repo itself.
const anyLegacyImported = $derived(gitSyncSettings.repositories.some((r) => r.legacyImported))
// Track changes in repositories
const repoChanges = $derived(
(() => {
// Force reactivity check by accessing the trigger
repoReactivityTrigger;
return gitSyncSettings.repositories.map((repo, idx) => {
const repoValid = isRepoValid(idx)
// If there were no initial repos, treat each repo as changed only when valid
if (!initialGitSyncSettings || !initialGitSyncSettings.repositories || initialGitSyncSettings.repositories.length === 0) {
return repoValid
}
const initial = initialGitSyncSettings.repositories.find(
initialRepo => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
)
// If no matching initial repo found, this is a new repo - changed only when valid
if (!initial) return repoValid
// Handle array ordering for consistent comparison
const settings1 = {
include_path: [...(initial.settings?.include_path ?? [])].sort(),
exclude_path: [...(initial.settings?.exclude_path ?? [])].sort(),
extra_include_path: [...(initial.settings?.extra_include_path ?? [])].sort(),
include_type: [...(initial.settings?.include_type ?? [])].sort()
};
const settings2 = {
include_path: [...(repo.settings?.include_path ?? [])].sort(),
exclude_path: [...(repo.settings?.exclude_path ?? [])].sort(),
extra_include_path: [...(repo.settings?.extra_include_path ?? [])].sort(),
include_type: [...(repo.settings?.include_type ?? [])].sort()
};
// Compare all properties in a consistent way
const isChanged = !deepEqual(
{
settings: settings1,
use_individual_branch: initial.use_individual_branch,
group_by_folder: initial.group_by_folder,
script_path: initial.script_path,
git_repo_resource_path: initial.git_repo_resource_path,
exclude_types_override: [...(initial.exclude_types_override ?? [])].sort()
},
{
settings: settings2,
use_individual_branch: repo.use_individual_branch,
group_by_folder: repo.group_by_folder,
script_path: repo.script_path,
git_repo_resource_path: repo.git_repo_resource_path,
exclude_types_override: [...(repo.exclude_types_override ?? [])].sort()
}
)
return isChanged && repoValid
})
})()
)
const hasAnyChanges = $derived(
repoChanges.some(Boolean) ||
anyLegacyImported ||
// Check if the set of valid repos has changed (added/removed repos)
(() => {
if (!initialGitSyncSettings?.repositories) return gitSyncSettings.repositories.filter((_,i)=>isRepoValid(i)).length > 0
const initialValidPaths = new Set(
initialGitSyncSettings.repositories
.filter(r => !emptyString(r.git_repo_resource_path))
.map(r => r.git_repo_resource_path)
)
const currentValidPaths = new Set(
gitSyncSettings.repositories
.filter((_,i) => isRepoValid(i))
.map(r => r.git_repo_resource_path)
)
// Check if sets are different (repos added or removed)
return initialValidPaths.size !== currentValidPaths.size ||
[...initialValidPaths].some(path => !currentValidPaths.has(path)) ||
[...currentValidPaths].some(path => !initialValidPaths.has(path))
})()
)
// Helper that tells if a repo card is valid (resource selected and not duplicated)
function isRepoValid(idx: number): boolean {
const repo = gitSyncSettings.repositories[idx]
if (!repo) return false
if (emptyString(repo.git_repo_resource_path)) return false
return !isRepoDuplicate(idx)
}
// Helper: true if repo shares its resource with an earlier repo
function isRepoDuplicate(idx: number): boolean {
const repo = gitSyncSettings.repositories[idx]
if (!repo || emptyString(repo.git_repo_resource_path)) return false
const firstIdx = gitSyncSettings.repositories.findIndex(r => r.git_repo_resource_path === repo.git_repo_resource_path)
return firstIdx !== idx
}
function serializeRepo(repo: GitSyncRepository) {
const serialized: any = {
script_path: repo.script_path,
git_repo_resource_path: `$res:${repo.git_repo_resource_path.replace('$res:', '')}`,
use_individual_branch: repo.use_individual_branch,
group_by_folder: repo.group_by_folder,
settings: repo.settings
}
// exclude_types_override should never be included for migrated repos (only legacy repos have it)
// Migration removes excluded types from include_type and drops exclude_types_override
return serialized
}
async function saveRepoSettings(idx: number): Promise<void> {
const currentRepo = gitSyncSettings.repositories[idx]
if (!currentRepo || !isRepoValid(idx)) {
sendUserToast('Cannot save invalid repository (missing or duplicate resource)', true)
return
}
// If we started with empty settings, we need to save all valid repositories
if (!initialGitSyncSettings || !initialGitSyncSettings.repositories || initialGitSyncSettings.repositories.length === 0) {
// For new repositories starting from empty, save all current repositories with valid resources
const validRepositories = gitSyncSettings.repositories
.filter((_,i)=>isRepoValid(i))
.map(repo => serializeRepo(repo))
await WorkspaceService.editWorkspaceGitSyncConfig({
workspace: $workspaceStore!,
requestBody: {
git_sync_settings: { repositories: validRepositories }
}
})
// Mark all repos migrated and reset legacy arrays
gitSyncSettings.repositories.forEach(r => r.legacyImported = false)
legacyWorkspaceIncludePath = []
legacyWorkspaceIncludeType = []
// Update initial settings to reflect what we just saved
initialGitSyncSettings = JSON.parse(JSON.stringify(gitSyncSettings))
} else {
// Build repositories array: include all repos but serialize differently based on migration status
let repositories: any[] = []
// Process all repos that should be in the final payload
for (const repo of gitSyncSettings.repositories) {
if (repo === currentRepo) {
// This is the repo we're saving - migrate if legacy, otherwise serialize normally
if (currentRepo.legacyImported) {
// Migrate legacy repo: remove excluded types from include_type and drop exclude_types_override
const migratedRepo = {
...currentRepo,
settings: {
...currentRepo.settings,
include_type: currentRepo.settings.include_type.filter(
type => !currentRepo.exclude_types_override?.includes(type)
)
},
exclude_types_override: [], // Clear this for migrated repo
legacyImported: false
}
repositories.push(serializeRepo(migratedRepo))
// Update the current repo in the UI to reflect migration
Object.assign(currentRepo, migratedRepo)
} else {
repositories.push(serializeRepo(currentRepo))
}
} else if (repo.legacyImported) {
// This is a legacy repo - serialize without settings field at all
const legacyRepoData: any = {
script_path: repo.script_path,
git_repo_resource_path: `$res:${repo.git_repo_resource_path.replace('$res:', '')}`,
use_individual_branch: repo.use_individual_branch,
group_by_folder: repo.group_by_folder
}
// Include exclude_types_override if it has values
if (repo.exclude_types_override && repo.exclude_types_override.length > 0) {
legacyRepoData.exclude_types_override = repo.exclude_types_override
}
repositories.push(legacyRepoData)
} else {
// This is an already-migrated repo
repositories.push(serializeRepo(repo))
}
}
// Mark current repo as migrated
currentRepo.legacyImported = false;
// Check if there are still legacy repos
const remainingLegacy = gitSyncSettings.repositories.some(r => r.legacyImported)
const gitSyncPayload: any = {
git_sync_settings: {
repositories,
...(remainingLegacy && {
include_path: legacyWorkspaceIncludePath,
include_type: legacyWorkspaceIncludeType
})
}
}
console.log('Sending payload:', JSON.stringify(gitSyncPayload, null, 2))
await WorkspaceService.editWorkspaceGitSyncConfig({
workspace: $workspaceStore!,
requestBody: gitSyncPayload
})
// Update initial settings to reflect current state
initialGitSyncSettings = JSON.parse(JSON.stringify(gitSyncSettings))
// if no more legacy repos, clear workspace-level legacy arrays
if (!remainingLegacy) {
legacyWorkspaceIncludePath = []
legacyWorkspaceIncludeType = []
}
}
sendUserToast('Repository settings updated')
}
async function editWorkspaceCommand(platform: 'slack' | 'teams'): Promise<void> {
if (platform === 'slack') {
@@ -466,50 +166,6 @@
}
}
async function editWindmillGitSyncSettings(): Promise<void> {
// Filter out repositories with empty resource paths before processing
const validRepos = gitSyncSettings.repositories.filter((_,i)=>isRepoValid(i))
let alreadySeenResource: string[] = []
let repositories = validRepos.map((repo) => {
alreadySeenResource.push(repo.git_repo_resource_path)
return serializeRepo(repo)
})
if (alreadySeenResource.some((res, index) => alreadySeenResource.indexOf(res) !== index)) {
sendUserToast('Same Git resource used more than once', true)
return
}
if (repositories.length > 0) {
await WorkspaceService.editWorkspaceGitSyncConfig({
workspace: $workspaceStore!,
requestBody: {
git_sync_settings: {
repositories
}
}
})
// Update initial settings to reflect what we just saved
initialGitSyncSettings = {
repositories: gitSyncSettings.repositories.filter((_,i)=>isRepoValid(i))
}
sendUserToast('Workspace Git sync settings updated')
gitSyncSettings.repositories.forEach(r => r.legacyImported = false);
legacyWorkspaceIncludePath = [];
legacyWorkspaceIncludeType = [];
} else {
await WorkspaceService.editWorkspaceGitSyncConfig({
workspace: $workspaceStore!,
requestBody: {
git_sync_settings: { repositories: [] }
}
})
initialGitSyncSettings = { repositories: [] }
sendUserToast('Workspace Git sync settings updated (no repositories)')
}
}
async function editWorkspaceDefaultApp(appPath: string | undefined): Promise<void> {
if (emptyString(appPath)) {
@@ -613,55 +269,6 @@
s3ResourceSettings = convertBackendSettingsToFrontendSettings(settings.large_file_storage)
if (settings.git_sync !== undefined && settings.git_sync !== null) {
gitSyncTestJobs = []
// Derive workspace-level legacy defaults (outside repositories)
const workspaceLegacyIncludePath: string[] = (settings.git_sync as any)?.include_path ?? [];
const workspaceLegacyIncludeTypeRaw: ObjectType[] = (settings.git_sync as any)?.include_type ?? [];
// Note: exclude_types_override is only at repository level, not workspace level
const workspaceLegacyIncludeType: ObjectType[] = [...workspaceLegacyIncludeTypeRaw];
legacyWorkspaceIncludePath = [...workspaceLegacyIncludePath];
legacyWorkspaceIncludeType = [...workspaceLegacyIncludeType];
gitSyncSettings.repositories = (settings.git_sync.repositories ?? []).map((repo: BackendGitRepositorySettings) => {
gitSyncTestJobs.push({
jobId: undefined,
status: undefined
})
// Now we have proper nested settings structure from the backend
const defaultTypes: ObjectType[] = workspaceLegacyIncludeType.length > 0
? [...workspaceLegacyIncludeType]
: (['script', 'flow', 'app', 'folder'] as ObjectType[]);
// Check if this is a legacy repo (no nested settings object)
const isRepoLegacy = !repo.settings;
const repoExcludeTypesOverride = repo.exclude_types_override ?? [];
const repoSettings: GitRepositorySettings = {
include_path: repo.settings?.include_path ?? [...workspaceLegacyIncludePath],
exclude_path: repo.settings?.exclude_path ?? [],
extra_include_path: repo.settings?.extra_include_path ?? [],
include_type: (repo.settings?.include_type ?? [...defaultTypes]) as ObjectType[]
};
return {
...repo,
git_repo_resource_path: repo.git_repo_resource_path.replace('$res:', ''),
collapsed: repo.collapsed ?? false,
settings: repoSettings,
exclude_types_override: repoExcludeTypesOverride,
legacyImported: isRepoLegacy && (legacyWorkspaceIncludePath.length > 0 || legacyWorkspaceIncludeType.length > 0)
} satisfies GitSyncRepository
})
// Store initial settings
initialGitSyncSettings = JSON.parse(JSON.stringify(gitSyncSettings))
} else {
gitSyncSettings.repositories = []
gitSyncTestJobs = []
initialGitSyncSettings = undefined
}
if (settings.deploy_ui != undefined && settings.deploy_ui != null) {
deployUiSettings = {
include_path:
@@ -732,53 +339,9 @@
}
}
async function runGitSyncTestJob(settingsIdx: number) {
let gitSyncRepository = gitSyncSettings.repositories[settingsIdx]
if (emptyString(gitSyncRepository.script_path)) {
return
}
let jobId = await JobService.runScriptByPath({
workspace: $workspaceStore!,
path: hubPaths.gitSyncTest,
requestBody: {
repo_url_resource_path: gitSyncRepository.git_repo_resource_path.replace('$res:', '')
},
skipPreprocessor: true
})
gitSyncTestJobs[settingsIdx] = {
jobId: jobId,
status: 'running'
}
gitSyncTestJobs = [...gitSyncTestJobs]
tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({
workspace: $workspaceStore!,
id: jobId
})
gitSyncTestJobs[settingsIdx].status = testResult.success ? 'success' : 'failure'
gitSyncTestJobs = [...gitSyncTestJobs]
},
timeoutCode: async () => {
try {
await JobService.cancelQueuedJob({
workspace: $workspaceStore!,
id: jobId,
requestBody: {
reason: 'Git sync test job timed out after 5s'
}
})
} catch (err) {
console.error(err)
} finally {
gitSyncTestJobs[settingsIdx].status = 'failure'
gitSyncTestJobs = [...gitSyncTestJobs]
}
},
interval: 500,
timeout: 5000
})
}
async function editCriticalAlertMuteSetting() {
await SettingService.workspaceMuteCriticalAlertsUi({
@@ -958,7 +521,7 @@
<DeployToSetting bind:workspaceToDeployTo bind:deployUiSettings />
{:else}
<div class="my-2"
><Alert type="error" title="Enterprise license required"
><Alert type="warning" title="Enterprise license required"
>Deploy to staging/prod from the web UI is only available with an enterprise license</Alert
></div
>
@@ -1009,7 +572,7 @@
{:else if slack_tabs === 'teams_commands'}
{#if !$enterpriseLicense}
<div class="pt-4"></div>
<Alert type="info" title="Workspace Teams commands is an EE feature">
<Alert type="warning" title="Workspace Teams commands is an EE feature">
Workspace Teams commands is a Windmill EE feature. It enables using your current Slack
/ Teams connection to run a custom script and send notifications.
</Alert>
@@ -1137,7 +700,7 @@
{:else if tab == 'error_handler'}
{#if !$enterpriseLicense}
<div class="pt-4"></div>
<Alert type="info" title="Workspace error handler is an EE feature">
<Alert type="warning" title="Workspace error handler is an EE feature">
Workspace error handler is a Windmill EE feature. It enables using your current Slack
connection or a custom script to send notifications anytime any job would fail.
</Alert>
@@ -1254,326 +817,12 @@
{:else if tab == 'windmill_lfs'}
<StorageSettings bind:s3ResourceSettings />
{:else if tab == 'git_sync'}
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class="text-primary text-lg font-semibold">Git Sync</div>
<Description link="https://www.windmill.dev/docs/advanced/git_sync">
Connect the Windmill workspace to a Git repository to automatically commit and push
scripts, flows, and apps to the repository on each deploy.
</Description>
</div>
<Alert type="info" title="Only new updates trigger git sync">
Only new changes matching the filters will trigger a git sync. You still need to initialize
the repo to the desired state first.
</Alert>
</div>
{#if !$enterpriseLicense}
<div class="mb-2"></div>
<Alert type="warning" title="Syncing workspace to Git is an EE feature">
Automatically saving scripts to a Git repository on each deploy is a Windmill EE feature.
</Alert>
<div class="mb-2"></div>
{/if}
{#if gitSyncSettings != undefined}
<div class="flex mt-5 mb-5 gap-8">
<Button
color="red"
startIcon={{ icon: Save }}
disabled={!$enterpriseLicense || !hasAnyChanges || gitSyncSettings.repositories.some((_,i)=>!isRepoValid(i))}
on:click={() => {
editWindmillGitSyncSettings()
console.log('Saving git sync settings', gitSyncSettings)
}}>Save all git sync settings {!$enterpriseLicense ? '(ee only)' : ''}</Button
>
<Button
color="dark"
target="_blank"
endIcon={{ icon: ExternalLink }}
href={`/runs?job_kinds=deploymentcallbacks&workspace=${$workspaceStore}`}
>See sync jobs</Button
>
</div>
<div class="pt-2"></div>
{#if Array.isArray(gitSyncSettings.repositories)}
{#each gitSyncSettings.repositories as repo, idx}
<div class="rounded-lg shadow-sm border p-0 w-full mb-4">
<!-- Card Header -->
<div class="flex items-center justify-between min-h-10 px-4 py-1 border-b">
<div class="flex items-center gap-2">
<span class="font-semibold">Repository #{idx + 1}</span>
<span class="text-xs text-tertiary pt-1 pl-8">
{repo.git_repo_resource_path}
</span>
</div>
<div class="flex items-center gap-2">
{#if (repoChanges[idx] || repo.legacyImported) && isRepoValid(idx)}
<Button
color="red"
size="xs"
on:click={() => saveRepoSettings(idx)}
startIcon={{ icon: Save }}
>
Save changes
</Button>
<Button
color="light"
size="xs"
on:click={() => {
// Revert to initial repository settings
if (initialGitSyncSettings?.repositories[idx]) {
gitSyncSettings.repositories[idx] = JSON.parse(JSON.stringify(initialGitSyncSettings.repositories[idx]));
sendUserToast('Reverted repository settings');
}
}}
startIcon={{ icon: RotateCcw }}
>
Revert
</Button>
{/if}
<button
class="text-secondary hover:text-primary focus:outline-none"
onclick={() => (repo.collapsed = !repo.collapsed)}
aria-label="Toggle collapse"
>
{#if repo.collapsed}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
</svg>
{/if}
</button>
<button
transition:fade|local={{ duration: 100 }}
class="rounded-full p-2 bg-surface-secondary duration-200 hover:bg-surface-hover"
aria-label="Remove repository"
onclick={() => {
gitSyncSettings.repositories = gitSyncSettings.repositories.filter((_, i) => i !== idx)
}}
>
<Trash size={14} />
</button>
</div>
</div>
{#if !repo.collapsed}
<div class="px-4 py-2">
<div class="flex mt-5 mb-1 gap-1">
{#key repo}
<div class="pt-1 font-semibold">Resource: </div>
<ResourcePicker
bind:value={repo.git_repo_resource_path}
resourceType={'git_repository'}
/>
{#if !emptyString(repo.git_repo_resource_path)}
<Button
disabled={emptyString(repo.script_path)}
color="dark"
on:click={() => runGitSyncTestJob(idx)}
size="xs">Test connection</Button
>
{/if}
{/key}
</div>
{#if !emptyString(repo.git_repo_resource_path)}
<div class="flex mb-5 text-normal text-2xs gap-1">
{#if isRepoDuplicate(idx)}
<span class="text-red-700">Using the same resource twice is not allowed.</span
>
{/if}
{#if gitSyncTestJobs[idx].status !== undefined}
{#if gitSyncTestJobs[idx].status === 'running'}
<RotateCw size={14} class="animate-spin" />
{:else if gitSyncTestJobs[idx].status === 'success'}
<CheckCircle2 size={14} class="text-green-600" />
{:else}
<XCircle size={14} class="text-red-700" />
{/if}
Git sync resource checked via Windmill job
<a
target="_blank"
href={`/run/${gitSyncTestJobs[idx].jobId}?workspace=${$workspaceStore}`}
>
{gitSyncTestJobs[idx].jobId}
</a>WARNING: Only read permissions are verified.
{/if}
</div>
{#if repo.legacyImported}
<Alert type="warning" title="Legacy git sync settings imported">
This repository was initialized from workspace-level legacy Git-Sync settings. Review the filters and press <b>Save</b> to migrate.
</Alert>
{/if}
<div class="flex flex-col mt-5 mb-1 gap-4">
{#if gitSyncSettings && repo}
{#if repo.script_path != latestGitSyncHubScript}
<Alert type="warning" title="Script version mismatch">
The git sync version for this repository is not latest. Current: <a
target="_blank"
href="https://hub.windmill.dev/scripts/windmill/6943/sync-script-to-git-repo-windmill/9014/versions"
>{repo.script_path}</a
>, latest:
<a
target="_blank"
href="https://hub.windmill.dev/scripts/windmill/6943/sync-script-to-git-repo-windmill/9014/versions"
>{latestGitSyncHubScript}</a
>
<div class="flex mt-2">
<Button
size="xs"
color="dark"
on:click={() => {
repo.script_path = latestGitSyncHubScript
}}
>Update git sync script (require save git settings to be applied)</Button
>
</div>
</Alert>
{/if}
<GitSyncFilterSettings
git_repo_resource_path={repo.git_repo_resource_path}
bind:include_path={repo.settings.include_path}
bind:include_type={repo.settings.include_type}
bind:exclude_types_override={repo.exclude_types_override}
isLegacyRepo={repo.legacyImported}
bind:excludes={repo.settings.exclude_path}
bind:extraIncludes={repo.settings.extra_include_path}
bind:yamlText
onSettingsChange={(settings) => {
yamlText = settings.yaml
// Force reactivity update
repoReactivityTrigger = repoReactivityTrigger + 1
}}
/>
<div class="w-1/3 flex gap-2">
<InitGitRepoPopover
gitRepoResourcePath={repo.git_repo_resource_path}
uiState={{
include_path: repo.settings.include_path,
exclude_path: repo.settings.exclude_path || [],
extra_include_path: repo.settings.extra_include_path || [],
include_type: repo.settings.include_type
}}
/>
<PullGitRepoPopover
gitRepoResourcePath={repo.git_repo_resource_path}
uiState={{
include_path: repo.settings.include_path,
exclude_path: repo.settings.exclude_path || [],
extra_include_path: repo.settings.extra_include_path || [],
include_type: repo.settings.include_type
}}
onFilterUpdate={(filters: { include_path: string[], exclude_path: string[], extra_include_path: string[], include_type: string[] }) => {
// Direct prop update - much simpler!
repo.settings.include_path = filters.include_path
repo.settings.exclude_path = filters.exclude_path
repo.settings.extra_include_path = filters.extra_include_path
repo.settings.include_type = filters.include_type as ObjectType[]
}}
/>
</div>
<Toggle
disabled={emptyString(repo.git_repo_resource_path)}
bind:checked={repo.use_individual_branch}
options={{
right: 'Create one branch per deployed object',
rightTooltip:
"If set, Windmill will create a unique branch per object being pushed based on its path, prefixed with 'wm_deploy/'."
}}
/>
<Toggle
disabled={emptyString(repo.git_repo_resource_path) ||
!repo.use_individual_branch}
bind:checked={repo.group_by_folder}
options={{
right: 'Group deployed objects by folder',
rightTooltip:
'Instead of creating a branch per object, Windmill will create a branch per folder containing objects being deployed.'
}}
/>
{/if}
</div>
{:else}
<div class="text-tertiary text-sm mt-3 mb-2">
Select a git repository resource to configure sync settings.
</div>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
<div class="flex mt-5 mb-5 gap-1">
<Button
color="none"
variant="border"
btnClasses="mt-1"
on:click={() => {
gitSyncSettings.repositories = [
...gitSyncSettings.repositories,
{
script_path: latestGitSyncHubScript,
git_repo_resource_path: '',
use_individual_branch: false,
group_by_folder: false,
collapsed: false,
settings: {
include_path: [...DEFAULT_INCLUDE_PATH],
exclude_path: [...DEFAULT_EXCLUDE_PATH],
extra_include_path: [...DEFAULT_EXTRA_INCLUDE_PATH],
include_type: ['script', 'flow', 'app', 'folder'] as ObjectType[]
},
exclude_types_override: [],
legacyImported: false
}
]
gitSyncTestJobs = [
...gitSyncTestJobs,
{
jobId: undefined,
status: undefined
}
]
}}
id="git-sync-add-connection"
startIcon={{ icon: Plus }}
>
Add connection
</Button>
</div>
{#if $workspaceStore}
<GitSyncSection />
{:else}
<Loader2 class="animate-spin mt-4" size={20} />
<div class="flex items-center justify-center p-8">
<div class="text-sm text-secondary">Loading workspace...</div>
</div>
{/if}
{:else if tab == 'default_app'}
<div class="flex flex-col gap-4 my-8">
@@ -1590,7 +839,7 @@
</div>
</div>
{#if !$enterpriseLicense}
<Alert type="info" title="Windmill EE only feature">
<Alert type="warning" title="Windmill EE only feature">
Default app can only be set on Windmill Enterprise Edition.
</Alert>
{/if}
@@ -1669,5 +918,7 @@
{/if}
</CenteredPage>
<style>
</style>