diff --git a/App.tsx b/App.tsx index b7fe4f3..c5c5046 100644 --- a/App.tsx +++ b/App.tsx @@ -159,6 +159,7 @@ const App: React.FC = () => { cloneRepository, launchApplication, launchExecutable, + validateWorkflow, logs, clearLogs, isProcessing, @@ -1254,6 +1255,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) { @@ -1745,6 +1751,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 93dd719..2b92667 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'; @@ -59,6 +59,7 @@ interface RepoEditViewProps { defaultCategoryId?: string; onOpenWeblink: (url: string) => void; detectedExecutables: Record; + onValidateWorkflow: (repo: Repository, relativePath: string) => Promise<'success' | 'failed'>; } const NEW_REPO_TEMPLATE: Omit = { @@ -164,6 +165,21 @@ const STEP_CATEGORIES = [ ]; const PROTECTED_BRANCH_IDENTIFIERS = new Set(['main', 'origin', 'origin/main']); +const WORKFLOW_TEMPLATE_MIN_HEIGHT = 160; +const WORKFLOW_EDITOR_MIN_HEIGHT = 220; + +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('/'); @@ -1875,7 +1891,7 @@ const CommitListItem: React.FC = ({ commit, highlight }) => ); }; -const RepoEditView: React.FC = ({ onSave, onCancel, repository, onRefreshState, setToast, setStatusBarMessage, confirmAction, defaultCategoryId, onOpenWeblink, detectedExecutables }) => { +const RepoEditView: React.FC = ({ onSave, onCancel, repository, onRefreshState, setToast, setStatusBarMessage, confirmAction, defaultCategoryId, onOpenWeblink, detectedExecutables, onValidateWorkflow }) => { const logger = useLogger(); const [formData, setFormData] = useState>(() => repository || NEW_REPO_TEMPLATE); @@ -1923,7 +1939,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([]); @@ -2016,6 +2032,95 @@ 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); + const workflowEditorRegionRef = useRef(null); + const [workflowTemplatesPaneHeight, setWorkflowTemplatesPaneHeight] = useState(240); + const [isWorkflowTemplatesPaneResizing, setIsWorkflowTemplatesPaneResizing] = useState(false); + + useEffect(() => { + selectedWorkflowPathRef.current = selectedWorkflowPath; + }, [selectedWorkflowPath]); + + useEffect(() => { + setWorkflowFiles([]); + setSelectedWorkflowPath(null); + setWorkflowEditorContent(''); + setWorkflowOriginalContent(''); + setWorkflowError(null); + setWorkflowTemplates([]); + setNewWorkflowTemplateId(''); + setNewWorkflowFilename('ci.yml'); + }, [repository?.id]); + + const beginWorkflowTemplatesResize = useCallback((event: React.MouseEvent) => { + event.preventDefault(); + setIsWorkflowTemplatesPaneResizing(true); + }, []); + + const handleWorkflowTemplatesMouseMove = useCallback((event: MouseEvent) => { + if (!isWorkflowTemplatesPaneResizing || !workflowEditorRegionRef.current) { + return; + } + + const containerRect = workflowEditorRegionRef.current.getBoundingClientRect(); + const totalHeight = containerRect.height; + if (totalHeight <= 0) { + return; + } + + const distanceFromBottom = containerRect.bottom - event.clientY; + const availableForTemplates = totalHeight - WORKFLOW_EDITOR_MIN_HEIGHT; + const maxHeight = availableForTemplates > 0 + ? availableForTemplates + : Math.min(WORKFLOW_TEMPLATE_MIN_HEIGHT, totalHeight); + const minHeight = availableForTemplates > WORKFLOW_TEMPLATE_MIN_HEIGHT + ? WORKFLOW_TEMPLATE_MIN_HEIGHT + : Math.max(Math.min(WORKFLOW_TEMPLATE_MIN_HEIGHT, availableForTemplates), 0); + + const safeMinHeight = Math.min(minHeight, maxHeight); + const safeMaxHeight = Math.max(maxHeight, safeMinHeight); + + const clampedHeight = Math.min( + Math.max(distanceFromBottom, safeMinHeight), + safeMaxHeight, + ); + + setWorkflowTemplatesPaneHeight(clampedHeight); + }, [isWorkflowTemplatesPaneResizing]); + + const endWorkflowTemplatesResize = useCallback(() => { + setIsWorkflowTemplatesPaneResizing(false); + }, []); + + useEffect(() => { + if (!isWorkflowTemplatesPaneResizing) { + return undefined; + } + + window.addEventListener('mousemove', handleWorkflowTemplatesMouseMove); + window.addEventListener('mouseup', endWorkflowTemplatesResize); + + return () => { + window.removeEventListener('mousemove', handleWorkflowTemplatesMouseMove); + window.removeEventListener('mouseup', endWorkflowTemplatesResize); + }; + }, [isWorkflowTemplatesPaneResizing, handleWorkflowTemplatesMouseMove, endWorkflowTemplatesResize]); + 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"; @@ -2025,7 +2130,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(() => { @@ -3442,6 +3550,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]); @@ -3797,6 +4111,201 @@ 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" + /> + +
+ + )} +
+ {selectedWorkflowPath ? ( +
+ {isWorkflowLoading ? ( +

Loading workflow…

+ ) : ( +