From 47976fa5c3138d287802f4944edf9e78c9669380 Mon Sep 17 00:00:00 2001 From: stijnpotters Date: Wed, 18 Feb 2026 15:28:44 +0100 Subject: [PATCH 01/21] feat: implemented complete git version control --- src/main/frontend/app/app.css | 37 ++ .../app/components/git/diff-tab-view.tsx | 165 +++++ .../app/components/git/git-changes.tsx | 186 ++++++ .../app/components/git/git-commit-box.tsx | 42 ++ .../frontend/app/components/git/git-panel.tsx | 194 ++++++ .../app/components/git/git-toolbar.tsx | 115 ++++ src/main/frontend/app/components/toast.tsx | 5 + .../frontend/app/routes/editor/editor.tsx | 312 ++++++--- .../frontend/app/routes/editor/xml-utils.ts | 30 + .../projectlanding/clone-project-modal.tsx | 22 +- .../routes/projectlanding/project-landing.tsx | 4 +- .../app/routes/studio/canvas/flow.tsx | 96 ++- src/main/frontend/app/services/git-service.ts | 49 ++ .../frontend/app/services/project-service.ts | 4 +- .../frontend/app/stores/editor-tab-store.ts | 11 +- src/main/frontend/app/stores/git-store.ts | 107 +++ src/main/frontend/app/types/git.types.ts | 55 ++ src/main/frontend/app/types/project.types.ts | 5 +- .../flow/git/GitCommitRequestDTO.java | 3 + .../flow/git/GitCommitResultDTO.java | 3 + .../flow/git/GitController.java | 107 +++ .../flow/git/GitCredentialHelper.java | 162 +++++ .../flow/git/GitFileDiffDTO.java | 5 + .../flow/git/GitFilePathDTO.java | 3 + .../frankframework/flow/git/GitHunkDTO.java | 4 + .../flow/git/GitLogEntryDTO.java | 3 + .../flow/git/GitOperationException.java | 14 + .../flow/git/GitPullResultDTO.java | 3 + .../flow/git/GitPushResultDTO.java | 3 + .../frankframework/flow/git/GitService.java | 621 ++++++++++++++++++ .../flow/git/GitStageHunksDTO.java | 5 + .../frankframework/flow/git/GitStatusDTO.java | 14 + .../frankframework/flow/git/GitTokenDTO.java | 3 + .../flow/git/NotAGitRepositoryException.java | 10 + .../flow/project/ProjectCloneDTO.java | 2 +- .../flow/project/ProjectController.java | 17 +- .../flow/project/ProjectDTO.java | 7 +- .../flow/project/ProjectService.java | 20 +- .../flow/git/GitControllerTest.java | 176 +++++ .../flow/git/GitServiceTest.java | 230 +++++++ 40 files changed, 2705 insertions(+), 149 deletions(-) create mode 100644 src/main/frontend/app/components/git/diff-tab-view.tsx create mode 100644 src/main/frontend/app/components/git/git-changes.tsx create mode 100644 src/main/frontend/app/components/git/git-commit-box.tsx create mode 100644 src/main/frontend/app/components/git/git-panel.tsx create mode 100644 src/main/frontend/app/components/git/git-toolbar.tsx create mode 100644 src/main/frontend/app/routes/editor/xml-utils.ts create mode 100644 src/main/frontend/app/services/git-service.ts create mode 100644 src/main/frontend/app/stores/git-store.ts create mode 100644 src/main/frontend/app/types/git.types.ts create mode 100644 src/main/java/org/frankframework/flow/git/GitCommitRequestDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/GitCommitResultDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/GitController.java create mode 100644 src/main/java/org/frankframework/flow/git/GitCredentialHelper.java create mode 100644 src/main/java/org/frankframework/flow/git/GitFileDiffDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/GitFilePathDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/GitHunkDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/GitLogEntryDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/GitOperationException.java create mode 100644 src/main/java/org/frankframework/flow/git/GitPullResultDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/GitPushResultDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/GitService.java create mode 100644 src/main/java/org/frankframework/flow/git/GitStageHunksDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/GitStatusDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/GitTokenDTO.java create mode 100644 src/main/java/org/frankframework/flow/git/NotAGitRepositoryException.java create mode 100644 src/test/java/org/frankframework/flow/git/GitControllerTest.java create mode 100644 src/test/java/org/frankframework/flow/git/GitServiceTest.java diff --git a/src/main/frontend/app/app.css b/src/main/frontend/app/app.css index 0b8ba23d..850cf13a 100644 --- a/src/main/frontend/app/app.css +++ b/src/main/frontend/app/app.css @@ -122,6 +122,43 @@ body { @apply border-l-4 border-yellow-400 bg-yellow-200/30 transition-colors; } +.monaco-editor .hunk-glyph-unchecked, +.monaco-editor .hunk-glyph-checked { + cursor: pointer !important; + display: flex !important; + align-items: center; + justify-content: center; +} + +.monaco-editor .hunk-glyph-unchecked::after, +.monaco-editor .hunk-glyph-checked::after { + content: ''; + display: block; + width: 14px; + height: 14px; + border-radius: 3px; + margin-left: 6px; + margin-top: 2px; + border: 1.5px solid #6b7280; + background: transparent; + box-sizing: border-box; +} + +.monaco-editor .hunk-glyph-checked::after { + border-color: #22c55e; + background: #22c55e; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14'%3E%3Cpath d='M3.5 7.5L5.5 9.5L10.5 4.5' stroke='white' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-size: 14px 14px; +} + +.monaco-editor .hunk-glyph-unchecked:hover::after { + border-color: #22c55e; +} + +.monaco-editor .hunk-line-selected { + background: rgba(34, 197, 94, 0.08) !important; +} + :root { /* Allotment Styling */ --focus-border: var(--color-brand); diff --git a/src/main/frontend/app/components/git/diff-tab-view.tsx b/src/main/frontend/app/components/git/diff-tab-view.tsx new file mode 100644 index 00000000..3ed47b9d --- /dev/null +++ b/src/main/frontend/app/components/git/diff-tab-view.tsx @@ -0,0 +1,165 @@ +import { DiffEditor, type Monaco, type DiffOnMount, type MonacoDiffEditor } from '@monaco-editor/react' +import { useShallow } from 'zustand/react/shallow' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTheme } from '~/hooks/use-theme' +import { useGitStore } from '~/stores/git-store' +import type { DiffTabData } from '~/stores/editor-tab-store' +import type { GitHunk } from '~/types/git.types' +import type { editor } from 'monaco-editor' + +function getLanguage(filePath: string): string { + if (filePath.endsWith('.xml')) return 'xml' + if (filePath.endsWith('.json')) return 'json' + return 'plaintext' +} + +function applyHunkDecorations( + modifiedEditor: editor.IStandaloneCodeEditor, + monaco: Monaco, + hunks: GitHunk[], + selectedHunks: Set, + prevDecorations: string[], +): string[] { + const decorations: editor.IModelDeltaDecoration[] = [] + + for (const hunk of hunks) { + if (hunk.newCount <= 0) continue + const isSelected = selectedHunks.has(hunk.index) + const startLine = hunk.newStart + const endLine = hunk.newStart + hunk.newCount - 1 + + decorations.push({ + range: new monaco.Range(startLine, 1, startLine, 1), + options: { + glyphMarginClassName: isSelected ? 'hunk-glyph-checked' : 'hunk-glyph-unchecked', + glyphMarginHoverMessage: { value: isSelected ? 'Deselect chunk' : 'Select chunk' }, + }, + }) + + if (isSelected) { + decorations.push({ + range: new monaco.Range(startLine, 1, endLine, 1), + options: { + isWholeLine: true, + className: 'hunk-line-selected', + }, + }) + } + } + + return modifiedEditor.deltaDecorations(prevDecorations, decorations) +} + +interface DiffTabViewProps { + diffData: DiffTabData + onStageSelected: () => void +} + +export default function DiffTabView({ diffData, onStageSelected }: DiffTabViewProps) { + const theme = useTheme() + const { fileHunkStates, toggleFileHunk } = useGitStore( + useShallow((s) => ({ + fileHunkStates: s.fileHunkStates, + toggleFileHunk: s.toggleFileHunk, + })), + ) + + const filePath = diffData.filePath + const hunks = diffData.hunks + const hunkState = fileHunkStates[filePath] + const selectedHunks = useMemo(() => hunkState?.selectedHunks ?? new Set(), [hunkState?.selectedHunks]) + + const diffEditorRef = useRef(null) + const monacoRef = useRef(null) + const decorationsRef = useRef([]) + const [editorReady, setEditorReady] = useState(false) + + const hunksRef = useRef(hunks) + hunksRef.current = hunks + const toggleRef = useRef(toggleFileHunk) + toggleRef.current = toggleFileHunk + const filePathRef = useRef(filePath) + filePathRef.current = filePath + + useEffect(() => { + if (!editorReady || !diffEditorRef.current || !monacoRef.current) return + const modifiedEditor = diffEditorRef.current.getModifiedEditor() + decorationsRef.current = applyHunkDecorations( + modifiedEditor, + monacoRef.current, + hunks, + selectedHunks, + decorationsRef.current, + ) + }, [hunks, selectedHunks, editorReady]) + + const handleDiffMount: DiffOnMount = useCallback((diffEditor: MonacoDiffEditor, monaco: Monaco) => { + diffEditorRef.current = diffEditor + monacoRef.current = monaco + + const modifiedEditor = diffEditor.getModifiedEditor() + modifiedEditor.updateOptions({ glyphMargin: true }) + + decorationsRef.current = applyHunkDecorations( + modifiedEditor, + monaco, + hunksRef.current, + useGitStore.getState().fileHunkStates[filePathRef.current]?.selectedHunks ?? new Set(), + decorationsRef.current, + ) + + modifiedEditor.onMouseDown((e: editor.IEditorMouseEvent) => { + const targetType = e.target.type + if (targetType === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN) { + const lineNumber = e.target.position?.lineNumber + if (lineNumber == null) return + + for (const hunk of hunksRef.current) { + if (hunk.newCount <= 0) continue + if (lineNumber >= hunk.newStart && lineNumber < hunk.newStart + hunk.newCount) { + toggleRef.current(filePathRef.current, hunk.index) + return + } + } + } + }) + + setEditorReady(true) + }, []) + + const language = getLanguage(filePath) + + return ( + <> +
+ {filePath} + {hunks.length > 0 && ( + + )} +
+
+ +
+ + ) +} diff --git a/src/main/frontend/app/components/git/git-changes.tsx b/src/main/frontend/app/components/git/git-changes.tsx new file mode 100644 index 00000000..e42ce447 --- /dev/null +++ b/src/main/frontend/app/components/git/git-changes.tsx @@ -0,0 +1,186 @@ +import { useEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import type { GitStatus, FileHunkState } from '~/types/git.types' + +interface GitChangesProps { + status: GitStatus | null + selectedFile: string | null + fileHunkStates: Record + onSelectFile: (file: string) => void + onToggleFile: (file: string) => void +} + +type SectionVariant = 'changes' | 'unversioned' | 'conflicts' + +const variantConfig: Record = { + changes: { accent: 'border-l-amber-500', badge: 'bg-amber-500/15 text-amber-400', badgeLabel: 'M' }, + unversioned: { accent: 'border-l-blue-500', badge: 'bg-blue-500/15 text-blue-400', badgeLabel: 'U' }, + conflicts: { accent: 'border-l-red-500', badge: 'bg-red-500/15 text-red-400', badgeLabel: 'C' }, +} + +function IndeterminateCheckbox({ + checked, + indeterminate, + onChange, + title, +}: { + checked: boolean + indeterminate: boolean + onChange: () => void + title: string +}) { + const ref = useRef(null) + + useEffect(() => { + if (ref.current) { + ref.current.indeterminate = indeterminate + } + }, [indeterminate]) + + return ( + e.stopPropagation()} + /> + ) +} + +function FileSection({ + title, + files, + selectedFile, + onSelectFile, + onToggleFile, + variant, + fileHunkStates, +}: { + title: string + files: string[] + selectedFile: string | null + onSelectFile: (file: string) => void + onToggleFile: (file: string) => void + variant: SectionVariant + fileHunkStates: Record +}) { + const [collapsed, setCollapsed] = useState(false) + const config = variantConfig[variant] + + if (files.length === 0) return null + + return ( +
+ + {!collapsed && ( +
+ {files.map((file) => { + const fileName = file.split('/').pop() || file + const dirPath = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '' + + const hunkState = fileHunkStates[file] + let checkboxChecked = false + let checkboxIndeterminate = false + + if (hunkState && hunkState.totalHunks > 0) { + const selectedCount = hunkState.selectedHunks.size + if (selectedCount === hunkState.totalHunks) { + checkboxChecked = true + } else if (selectedCount > 0) { + checkboxIndeterminate = true + } + } + + return ( +
onSelectFile(file)} + > + onToggleFile(file)} + title={checkboxChecked ? 'Deselect all chunks' : 'Select all chunks'} + /> + + {config.badgeLabel} + + + {fileName} + {dirPath && {dirPath}} + +
+ ) + })} +
+ )} +
+ ) +} + +export default function GitChanges({ + status, + selectedFile, + fileHunkStates, + onSelectFile, + onToggleFile, +}: GitChangesProps) { + if (!status) return null + + const changedFiles = [...new Set([...status.staged, ...status.modified])] + + return ( +
+ + + {status.conflicting.length > 0 && ( + + )} +
+ ) +} diff --git a/src/main/frontend/app/components/git/git-commit-box.tsx b/src/main/frontend/app/components/git/git-commit-box.tsx new file mode 100644 index 00000000..48942fe1 --- /dev/null +++ b/src/main/frontend/app/components/git/git-commit-box.tsx @@ -0,0 +1,42 @@ +interface GitCommitBoxProps { + commitMessage: string + onMessageChange: (message: string) => void + onCommit: () => void + hasSelectedChunks: boolean + isLoading: boolean +} + +export default function GitCommitBox({ + commitMessage, + onMessageChange, + onCommit, + hasSelectedChunks, + isLoading, +}: GitCommitBoxProps) { + const canCommit = hasSelectedChunks && !!commitMessage.trim() && !isLoading + + return ( +
+