From 3296e4cf14efd8dce4cfed2778ab9b02630aff41 Mon Sep 17 00:00:00 2001 From: Tim Sinaeve Date: Sun, 16 Nov 2025 11:56:45 +0100 Subject: [PATCH 1/3] fix: bind workflow validation sender --- App.tsx | 7 + CHANGELOG.md | 2 +- components/modals/RepoFormModal.tsx | 442 +++++++++++++++++++++++++++- electron/electron.d.ts | 9 +- electron/main.ts | 300 ++++++++++++++++++- electron/preload.ts | 11 +- hooks/useRepositoryManager.ts | 28 ++ types.ts | 18 ++ 8 files changed, 799 insertions(+), 18 deletions(-) diff --git a/App.tsx b/App.tsx index e735e8c..aa73dfe 100644 --- a/App.tsx +++ b/App.tsx @@ -159,6 +159,7 @@ const App: React.FC = () => { cloneRepository, launchApplication, launchExecutable, + validateWorkflow, logs, clearLogs, isProcessing, @@ -1253,6 +1254,11 @@ const App: React.FC = () => { }); }, [clearLogs]); + const handleValidateWorkflow = useCallback((repo: Repository, relativePath: string) => { + openLogPanelForRepo(repo.id, false); + return validateWorkflow(repo, relativePath); + }, [openLogPanelForRepo, validateWorkflow]); + const handleCancelTask = useCallback((repoId: string) => { const repo = repositories.find(r => r.id === repoId); if (!repo) { @@ -1743,6 +1749,7 @@ const App: React.FC = () => { defaultCategoryId={repoFormState.defaultCategoryId} onOpenWeblink={handleOpenWeblink} detectedExecutables={detectedExecutables} + onValidateWorkflow={handleValidateWorkflow} />; case 'dashboard': default: diff --git a/CHANGELOG.md b/CHANGELOG.md index 97c303a..479bcff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- _No unreleased changes._ +- **Workflow Template Explorer:** Added a CI tab to the repository form so you can browse `.github/workflows` files, fork recommended templates, edit YAML in place, run validation via `yamllint`/`act`, and push commits without leaving the modal. ## [0.26.0] diff --git a/components/modals/RepoFormModal.tsx b/components/modals/RepoFormModal.tsx index 4768baa..0f9f671 100644 --- a/components/modals/RepoFormModal.tsx +++ b/components/modals/RepoFormModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import type { Repository, Task, TaskStep, ProjectSuggestion, GitRepository, SvnRepository, LaunchConfig, WebLinkConfig, Commit, BranchInfo, PythonCapabilities, ProjectInfo, DelphiCapabilities, NodejsCapabilities, LazarusCapabilities, ReleaseInfo, DockerCapabilities, GoCapabilities, RustCapabilities, MavenCapabilities, DotnetCapabilities } from '../../types'; +import type { Repository, Task, TaskStep, ProjectSuggestion, GitRepository, SvnRepository, LaunchConfig, WebLinkConfig, Commit, BranchInfo, PythonCapabilities, ProjectInfo, DelphiCapabilities, NodejsCapabilities, LazarusCapabilities, ReleaseInfo, DockerCapabilities, GoCapabilities, RustCapabilities, MavenCapabilities, DotnetCapabilities, WorkflowFileSummary, WorkflowTemplateSuggestion } from '../../types'; import { RepoStatus, BuildHealth, TaskStepType, VcsType } from '../../types'; import { PlusIcon } from '../icons/PlusIcon'; import { TrashIcon } from '../icons/TrashIcon'; @@ -57,6 +57,7 @@ interface RepoEditViewProps { defaultCategoryId?: string; onOpenWeblink: (url: string) => void; detectedExecutables: Record; + onValidateWorkflow: (repo: Repository, relativePath: string) => Promise<'success' | 'failed'>; } const NEW_REPO_TEMPLATE: Omit = { @@ -163,6 +164,19 @@ const STEP_CATEGORIES = [ const PROTECTED_BRANCH_IDENTIFIERS = new Set(['main', 'origin', 'origin/main']); +const normalizeWorkflowInputPath = (value: string): string => { + let normalized = (value || '').trim().replace(/\\/g, '/'); + if (!normalized) return ''; + if (normalized.startsWith('.github/')) { + normalized = normalized.slice('.github/'.length); + } + if (normalized.startsWith('workflows/')) { + normalized = normalized.slice('workflows/'.length); + } + normalized = normalized.replace(/^\/+/, ''); + return `.github/workflows/${normalized}`; +}; + const parseRemoteBranchIdentifier = (fullBranchName: string): { remoteName: string; branchName: string } | null => { const [remoteName, ...rest] = fullBranchName.split('/'); if (!remoteName || rest.length === 0) { @@ -1869,7 +1883,7 @@ const CommitListItem: React.FC = ({ commit, highlight }) => ); }; -const RepoEditView: React.FC = ({ onSave, onCancel, repository, onRefreshState, setToast, confirmAction, defaultCategoryId, onOpenWeblink, detectedExecutables }) => { +const RepoEditView: React.FC = ({ onSave, onCancel, repository, onRefreshState, setToast, confirmAction, defaultCategoryId, onOpenWeblink, detectedExecutables, onValidateWorkflow }) => { const logger = useLogger(); const [formData, setFormData] = useState>(() => repository || NEW_REPO_TEMPLATE); @@ -1917,7 +1931,7 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor return null; }); - const [activeTab, setActiveTab] = useState<'tasks' | 'history' | 'branches' | 'releases'>('tasks'); + const [activeTab, setActiveTab] = useState<'tasks' | 'history' | 'branches' | 'releases' | 'ci'>('tasks'); // State for History Tab const [commits, setCommits] = useState([]); @@ -1973,6 +1987,38 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor const [releasesError, setReleasesError] = useState(null); const [editingRelease, setEditingRelease] = useState & { isNew?: boolean } | null>(null); + const [workflowFiles, setWorkflowFiles] = useState([]); + const [workflowFilesLoading, setWorkflowFilesLoading] = useState(false); + const [selectedWorkflowPath, setSelectedWorkflowPath] = useState(null); + const [workflowEditorContent, setWorkflowEditorContent] = useState(''); + const [workflowOriginalContent, setWorkflowOriginalContent] = useState(''); + const [workflowError, setWorkflowError] = useState(null); + const [isWorkflowLoading, setIsWorkflowLoading] = useState(false); + const [isWorkflowSaving, setIsWorkflowSaving] = useState(false); + const [workflowTemplates, setWorkflowTemplates] = useState([]); + const [workflowTemplatesLoading, setWorkflowTemplatesLoading] = useState(false); + const [newWorkflowTemplateId, setNewWorkflowTemplateId] = useState(''); + const [newWorkflowFilename, setNewWorkflowFilename] = useState('ci.yml'); + const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false); + const [workflowCommitMessage, setWorkflowCommitMessage] = useState('chore: update workflow'); + const [isWorkflowCommitInProgress, setIsWorkflowCommitInProgress] = useState(false); + const selectedWorkflowPathRef = useRef(null); + + useEffect(() => { + selectedWorkflowPathRef.current = selectedWorkflowPath; + }, [selectedWorkflowPath]); + + useEffect(() => { + setWorkflowFiles([]); + setSelectedWorkflowPath(null); + setWorkflowEditorContent(''); + setWorkflowOriginalContent(''); + setWorkflowError(null); + setWorkflowTemplates([]); + setNewWorkflowTemplateId(''); + setNewWorkflowFilename('ci.yml'); + }, [repository?.id]); + const formInputStyle = "block w-full bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm py-1.5 px-3 text-gray-900 dark:text-white focus:outline-none focus:ring-blue-500 focus:border-blue-500"; const formLabelStyle = "block text-sm font-medium text-gray-700 dark:text-gray-300"; @@ -1982,7 +2028,10 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor const isSvnRepo = formData.vcs === VcsType.Svn; const supportsHistoryTab = isGitRepo || isSvnRepo; const supportsBranchTab = isGitRepo || isSvnRepo; + const supportsCiTab = isGitRepo; const isGitHubRepo = useMemo(() => isGitRepo && formData.remoteUrl?.includes('github.com'), [isGitRepo, formData.remoteUrl]); + const workflowDirty = selectedWorkflowPath !== null && workflowEditorContent !== workflowOriginalContent; + const selectedWorkflowFile = useMemo(() => workflowFiles.find(file => file.relativePath === selectedWorkflowPath) || null, [workflowFiles, selectedWorkflowPath]); // Debounce history search useEffect(() => { @@ -3382,6 +3431,212 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor }; + const loadWorkflowFile = useCallback(async (relativePath: string) => { + if (!repository?.localPath || !window.electronAPI?.readWorkflowFile) { + return; + } + setIsWorkflowLoading(true); + setWorkflowError(null); + try { + const result = await window.electronAPI.readWorkflowFile({ repoPath: repository.localPath, relativePath }); + if (result?.success && typeof result.content === 'string') { + setWorkflowEditorContent(result.content); + setWorkflowOriginalContent(result.content); + } else { + setWorkflowError(result?.error || 'Unable to load workflow.'); + } + } catch (error: any) { + logger.error('Failed to read workflow file', { repoId: repository?.id ?? null, relativePath, error: error?.message || error }); + setWorkflowError(error?.message || 'Unable to read workflow.'); + } finally { + setIsWorkflowLoading(false); + } + }, [repository, logger]); + + const fetchWorkflowFiles = useCallback(async (focus?: string | null) => { + if (!repository?.localPath || !window.electronAPI?.listWorkflowFiles) { + return; + } + setWorkflowFilesLoading(true); + try { + const files = await window.electronAPI.listWorkflowFiles(repository.localPath); + setWorkflowFiles(files); + if (files.length === 0) { + setSelectedWorkflowPath(null); + setWorkflowEditorContent(''); + setWorkflowOriginalContent(''); + return; + } + const desired = typeof focus === 'string' ? focus : selectedWorkflowPathRef.current; + const found = desired && files.some(file => file.relativePath === desired); + const nextPath = found ? desired! : files[0].relativePath; + if (nextPath) { + setSelectedWorkflowPath(nextPath); + await loadWorkflowFile(nextPath); + } + } catch (error: any) { + logger.error('Failed to load workflow files', { repoId: repository?.id ?? null, error: error?.message || error }); + setToast({ message: 'Failed to load workflow files.', type: 'error' }); + } finally { + setWorkflowFilesLoading(false); + } + }, [repository, loadWorkflowFile, logger, setToast]); + + const fetchWorkflowTemplates = useCallback(async () => { + if (!repository?.localPath || !window.electronAPI?.getWorkflowTemplates) { + setWorkflowTemplates([]); + return; + } + setWorkflowTemplatesLoading(true); + try { + const templates = await window.electronAPI.getWorkflowTemplates({ repoPath: repository.localPath, repoName: repository.name || '' }); + setWorkflowTemplates(templates || []); + if (templates && templates.length > 0) { + const firstTemplate = templates[0]; + setNewWorkflowTemplateId(prev => prev || firstTemplate.id); + setNewWorkflowFilename(prev => (prev && prev.trim().length > 0 ? prev : firstTemplate.filename)); + } + } catch (error: any) { + logger.error('Failed to load workflow templates', { repoId: repository?.id ?? null, error: error?.message || error }); + setToast({ message: 'Failed to load workflow templates.', type: 'error' }); + } finally { + setWorkflowTemplatesLoading(false); + } + }, [repository, logger, setToast]); + + const handleWorkflowSelection = useCallback((relativePath: string) => { + const select = () => { + setSelectedWorkflowPath(relativePath); + loadWorkflowFile(relativePath); + }; + if (workflowDirty) { + confirmAction({ + title: 'Discard unsaved changes?', + message: 'Switching workflows will discard unsaved edits.', + confirmText: 'Discard & Switch', + confirmButtonClass: 'bg-red-600 hover:bg-red-700 focus:ring-red-500', + onConfirm: select, + }); + return; + } + select(); + }, [workflowDirty, confirmAction, loadWorkflowFile]); + + const handleWorkflowSave = useCallback(async () => { + if (!repository?.localPath || !selectedWorkflowPath || !window.electronAPI?.writeWorkflowFile || !workflowDirty) { + return; + } + setIsWorkflowSaving(true); + setWorkflowError(null); + try { + const result = await window.electronAPI.writeWorkflowFile({ repoPath: repository.localPath, relativePath: selectedWorkflowPath, content: workflowEditorContent }); + if (result?.success) { + setWorkflowOriginalContent(workflowEditorContent); + setToast({ message: 'Workflow saved.', type: 'success' }); + } else { + const message = result?.error || 'Unable to save workflow.'; + setWorkflowError(message); + setToast({ message, type: 'error' }); + } + } catch (error: any) { + const message = error?.message || 'Unable to save workflow.'; + logger.error('Failed to save workflow file', { repoId: repository?.id ?? null, relativePath: selectedWorkflowPath, error: message }); + setWorkflowError(message); + setToast({ message, type: 'error' }); + } finally { + setIsWorkflowSaving(false); + } + }, [repository, selectedWorkflowPath, workflowEditorContent, workflowDirty, logger, setToast]); + + const handleWorkflowValidate = useCallback(async () => { + if (!repository || !selectedWorkflowPath) { + return; + } + if (workflowDirty) { + setToast({ message: 'Save the workflow before running validation.', type: 'info' }); + return; + } + try { + setToast({ message: 'Workflow validation started. Check the Task Log Panel for progress.', type: 'info' }); + const result = await onValidateWorkflow(repository, selectedWorkflowPath); + if (result === 'success') { + setToast({ message: 'Workflow validation completed.', type: 'success' }); + } else if (result === 'failed') { + setToast({ message: 'Validation reported issues. Review the Task Log Panel for details.', type: 'error' }); + } + } catch (error: any) { + setToast({ message: error?.message || 'Failed to start validation.', type: 'error' }); + } + }, [repository, selectedWorkflowPath, workflowDirty, onValidateWorkflow, setToast]); + + const handleCreateWorkflow = useCallback(async () => { + if (!repository?.localPath || !window.electronAPI?.createWorkflowFromTemplate) { + return; + } + const template = workflowTemplates.find(t => t.id === newWorkflowTemplateId) || workflowTemplates[0]; + if (!template) { + setToast({ message: 'No templates available to fork.', type: 'info' }); + return; + } + const normalizedPath = normalizeWorkflowInputPath(newWorkflowFilename); + if (!normalizedPath) { + setToast({ message: 'Enter a workflow file name.', type: 'info' }); + return; + } + setIsCreatingWorkflow(true); + try { + const result = await window.electronAPI.createWorkflowFromTemplate({ repoPath: repository.localPath, relativePath: normalizedPath, content: template.content }); + if (!result?.success) { + setToast({ message: result?.error || 'Failed to create workflow.', type: 'error' }); + return; + } + setToast({ message: 'Workflow created from template.', type: 'success' }); + await fetchWorkflowFiles(normalizedPath); + } catch (error: any) { + logger.error('Failed to create workflow from template', { repoId: repository?.id ?? null, error: error?.message || error }); + setToast({ message: error?.message || 'Failed to create workflow.', type: 'error' }); + } finally { + setIsCreatingWorkflow(false); + } + }, [repository, workflowTemplates, newWorkflowTemplateId, newWorkflowFilename, fetchWorkflowFiles, logger, setToast]); + + const handleApplyTemplateToEditor = useCallback((template: WorkflowTemplateSuggestion) => { + setWorkflowEditorContent(template.content); + setWorkflowError(null); + }, []); + + const handleWorkflowCommit = useCallback(async () => { + if (!repository || !selectedWorkflowPath || !window.electronAPI?.commitWorkflowFiles) { + return; + } + if (workflowDirty) { + setToast({ message: 'Save the workflow before committing changes.', type: 'info' }); + return; + } + setIsWorkflowCommitInProgress(true); + try { + const message = (workflowCommitMessage || '').trim() || `chore: update ${selectedWorkflowPath.split('/').pop()}`; + const result = await window.electronAPI.commitWorkflowFiles({ repo: repository, filePaths: [selectedWorkflowPath], message }); + if (result?.success) { + setToast({ message: 'Workflow changes committed and pushed.', type: 'success' }); + } else { + setToast({ message: result?.error || 'Failed to push workflow changes.', type: 'error' }); + } + } catch (error: any) { + logger.error('Failed to push workflow changes', { repoId: repository?.id ?? null, error: error?.message || error }); + setToast({ message: error?.message || 'Failed to push workflow changes.', type: 'error' }); + } finally { + setIsWorkflowCommitInProgress(false); + } + }, [repository, selectedWorkflowPath, workflowCommitMessage, workflowDirty, logger, setToast]); + + useEffect(() => { + if (activeTab === 'ci' && repository?.localPath) { + fetchWorkflowFiles(); + fetchWorkflowTemplates(); + } + }, [activeTab, repository?.localPath, fetchWorkflowFiles, fetchWorkflowTemplates]); + const selectedTask = useMemo(() => { return formData.tasks?.find(t => t.id === selectedTaskId) || null; }, [selectedTaskId, formData.tasks]); @@ -3755,6 +4010,184 @@ const RepoEditView: React.FC = ({ onSave, onCancel, repositor ); } + case 'ci': { + if (!supportsCiTab) { + return
Workflow editing is only available for Git repositories.
; + } + return ( +
+ +
+ {selectedWorkflowPath ? ( + <> +
+
+

{selectedWorkflowPath}

+ {selectedWorkflowFile && ( +

Updated {new Date(selectedWorkflowFile.mtimeMs).toLocaleString()}

+ )} + {workflowDirty &&

Unsaved changes

} +
+
+ + +
+
+
+ + setWorkflowCommitMessage(e.target.value)} + className={`${formInputStyle} text-xs flex-1`} + placeholder="chore: update workflow" + /> + +
+
+ {isWorkflowLoading ? ( +

Loading workflow…

+ ) : ( +