Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
47976fa
feat: implemented complete git version control
stijnpotters1 Feb 18, 2026
35befc6
Merge branch 'master' into feat/git-integration
stijnpotters1 Feb 18, 2026
a062557
refactor: simplify access token label and remove unused stage selecti…
stijnpotters1 Feb 18, 2026
4a5df71
Merge branch 'feat/git-integration' of https://github.com/frankframew…
stijnpotters1 Feb 18, 2026
1988432
feat: enhance git integration with token management and refresh funct…
stijnpotters1 Feb 18, 2026
52e909f
fix: improved tests
stijnpotters1 Feb 18, 2026
6147813
feat: implement lazy resolution of git executable for credential helper
stijnpotters1 Feb 19, 2026
dba2a55
feat: implement safe path resolution to prevent path traversal attacks
stijnpotters1 Feb 19, 2026
2d87528
test: enhance GitServiceTest with additional repository status checks…
stijnpotters1 Feb 19, 2026
004e722
test: enhance GitCredentialHelperTest with additional token resolutio…
stijnpotters1 Feb 19, 2026
ce6ead3
test: simplify GitCredentialHelperTest by removing unnecessary line b…
stijnpotters1 Feb 19, 2026
cab96d0
feat: enhance project opening with error handling for non-existent pr…
stijnpotters1 Feb 19, 2026
31fcc10
feat: implemented complete git version control
stijnpotters1 Feb 18, 2026
486b2bd
refactor: simplify access token label and remove unused stage selecti…
stijnpotters1 Feb 18, 2026
d3e43be
feat: enhance git integration with token management and refresh funct…
stijnpotters1 Feb 18, 2026
6fb6c18
fix: improved tests
stijnpotters1 Feb 18, 2026
296ff58
feat: implement lazy resolution of git executable for credential helper
stijnpotters1 Feb 19, 2026
1b66a96
feat: implement safe path resolution to prevent path traversal attacks
stijnpotters1 Feb 19, 2026
21adcfb
test: enhance GitServiceTest with additional repository status checks…
stijnpotters1 Feb 19, 2026
0123e7d
test: enhance GitCredentialHelperTest with additional token resolutio…
stijnpotters1 Feb 19, 2026
7e3b75a
test: simplify GitCredentialHelperTest by removing unnecessary line b…
stijnpotters1 Feb 19, 2026
f302886
feat: enhance project opening with error handling for non-existent pr…
stijnpotters1 Feb 19, 2026
4c69585
Merge branch 'feat/git-integration' of https://github.com/frankframew…
stijnpotters1 Feb 19, 2026
039d827
feat: implement autosave functionality with configurable delay and re…
stijnpotters1 Feb 19, 2026
f6f7ea5
feat: pulled master into branch
stijnpotters1 Feb 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/main/frontend/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
156 changes: 156 additions & 0 deletions src/main/frontend/app/components/git/diff-tab-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
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'

type CodeEditor = ReturnType<MonacoDiffEditor['getModifiedEditor']>

function getLanguage(filePath: string): string {
if (filePath.endsWith('.xml')) return 'xml'
if (filePath.endsWith('.json')) return 'json'
return 'plaintext'
}

function applyHunkDecorations(
modifiedEditor: CodeEditor,
monaco: Monaco,
hunks: GitHunk[],
selectedHunks: Set<number>,
prevDecorations: string[],
): string[] {
const decorations: Parameters<CodeEditor['deltaDecorations']>[1] = []

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
}

export default function DiffTabView({ diffData }: 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<number>(), [hunkState?.selectedHunks])

const diffEditorRef = useRef<MonacoDiffEditor | null>(null)
const monacoRef = useRef<Monaco | null>(null)
const decorationsRef = useRef<string[]>([])
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) => {
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 (
<>
<div className="border-b-border bg-background flex h-12 items-center border-b px-4">
<span className="text-sm font-medium">{filePath}</span>
</div>
<div className="min-h-0 flex-1">
<DiffEditor
original={diffData.oldContent}
modified={diffData.newContent}
language={language}
theme={`vs-${theme}`}
onMount={handleDiffMount}
options={{
readOnly: true,
automaticLayout: true,
renderSideBySide: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
glyphMargin: true,
}}
/>
</div>
</>
)
}
186 changes: 186 additions & 0 deletions src/main/frontend/app/components/git/git-changes.tsx
Original file line number Diff line number Diff line change
@@ -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<string, FileHunkState>
onSelectFile: (file: string) => void
onToggleFile: (file: string) => void
}

type SectionVariant = 'changes' | 'unversioned' | 'conflicts'

const variantConfig: Record<SectionVariant, { accent: string; badge: string; badgeLabel: string }> = {
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<HTMLInputElement>(null)

useEffect(() => {
if (ref.current) {
ref.current.indeterminate = indeterminate
}
}, [indeterminate])

return (
<input
ref={ref}
type="checkbox"
checked={checked}
onChange={onChange}
className="h-3 w-3 flex-shrink-0 cursor-pointer accent-green-500"
title={title}
onClick={(e) => 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<string, FileHunkState>
}) {
const [collapsed, setCollapsed] = useState(false)
const config = variantConfig[variant]

if (files.length === 0) return null

return (
<div className={clsx('border-l-2', config.accent)}>
<button
onClick={() => setCollapsed(!collapsed)}
className="text-foreground hover:bg-hover flex w-full cursor-pointer items-center gap-1.5 px-3 py-1.5 text-left text-xs font-semibold"
>
<span className="w-3 text-[10px]">{collapsed ? '▸' : '▾'}</span>
<span className="flex-1">{title}</span>
<span className="bg-foreground-active text-muted-foreground rounded-full px-1.5 py-0.5 text-[10px] leading-none font-medium">
{files.length}
</span>
</button>
{!collapsed && (
<div className="pb-1">
{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 (
<div
key={file}
className={clsx(
'group flex items-center gap-2 rounded-r px-3 py-1 text-xs hover:cursor-pointer',
selectedFile === file ? 'bg-selected' : 'hover:bg-hover',
)}
onClick={() => onSelectFile(file)}
>
<IndeterminateCheckbox
checked={checkboxChecked}
indeterminate={checkboxIndeterminate}
onChange={() => onToggleFile(file)}
title={checkboxChecked ? 'Deselect all chunks' : 'Select all chunks'}
/>
<span
className={clsx(
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-[10px] font-bold',
config.badge,
)}
>
{config.badgeLabel}
</span>
<span className="text-foreground min-w-0 flex-1 truncate" title={file}>
{fileName}
{dirPath && <span className="text-muted-foreground ml-1.5 text-[10px]">{dirPath}</span>}
</span>
</div>
)
})}
</div>
)}
</div>
)
}

export default function GitChanges({
status,
selectedFile,
fileHunkStates,
onSelectFile,
onToggleFile,
}: GitChangesProps) {
if (!status) return null

const changedFiles = [...new Set([...status.staged, ...status.modified])]

return (
<div className="border-b-border overflow-y-auto border-b">
<FileSection
title="Changes"
files={changedFiles}
selectedFile={selectedFile}
onSelectFile={onSelectFile}
onToggleFile={onToggleFile}
variant="changes"
fileHunkStates={fileHunkStates}
/>
<FileSection
title="Unversioned"
files={status.untracked}
selectedFile={selectedFile}
onSelectFile={onSelectFile}
onToggleFile={onToggleFile}
variant="unversioned"
fileHunkStates={fileHunkStates}
/>
{status.conflicting.length > 0 && (
<FileSection
title="Conflicts"
files={status.conflicting}
selectedFile={selectedFile}
onSelectFile={onSelectFile}
onToggleFile={onToggleFile}
variant="conflicts"
fileHunkStates={fileHunkStates}
/>
)}
</div>
)
}
Loading
Loading