feat: complete FlowGit Windows desktop app with Blueprint graphs
This commit is contained in:
parent
fe44dfeca0
commit
2c0b0ffc89
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Production builds
|
||||
dist/
|
||||
dist-electron/
|
||||
out/
|
||||
release/
|
||||
|
||||
# Vite & IDE caches
|
||||
.vite/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# SQLite & local databases
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.DS_Store
|
||||
125
PROJECT_STATE.md
Normal file
125
PROJECT_STATE.md
Normal file
@ -0,0 +1,125 @@
|
||||
# PROJECT_STATE.md — FlowGit Development Handoff
|
||||
|
||||
> **OPERATIONAL HANDOFF DOCUMENT** — Read this completely before making any changes.
|
||||
> Last updated: 2026-10-24T23:47 UTC+3
|
||||
|
||||
---
|
||||
|
||||
## A. Project Overview
|
||||
|
||||
- **Project name:** FlowGit
|
||||
- **Purpose:** A premium, professional-grade Git desktop application for Windows featuring a Blueprint-inspired visual Git graph, advanced visual diff viewer, and complete real Git workflow support.
|
||||
- **Target OS:** Windows 11 (primary), Windows 10 (fully compatible).
|
||||
- **Technology stack:**
|
||||
- Electron 33.4.11 (main process, sandboxed security boundaries, native window frame integration)
|
||||
- React 18.3.1 (renderer UI)
|
||||
- TypeScript 5.7.2 (strict mode enabled across main, preload, shared, and renderer)
|
||||
- Vite 6.4.5 + electron-vite 3.0.0 (production bundle and development server)
|
||||
- Tailwind CSS v4.1.0 + Axiom Design Tokens from DESIGN.md
|
||||
- better-sqlite3 (local SQLite metadata persistence)
|
||||
- electron-store (user preferences storage)
|
||||
- Zustand 5.0.2 + Immer (renderer reactive state stores)
|
||||
- TanStack React Query 5.62.8 (async state caching)
|
||||
- Vitest 2.1.9 (automated test suite with isolated temporary Git repositories)
|
||||
- **Current phase:** Phase 1 (Foundation) & Phase 2 (Core Git Workflows) & Phase 3 (Blueprint Graph & Advanced Diffs) Complete.
|
||||
- **Overall completion:** ~75% (Core Git operations, Changes, Diffs, History, Blueprint Graph, Branches, Tags, Stashes, Remotes, File Browser, Settings, and SQLite persistence fully operational and verified).
|
||||
|
||||
---
|
||||
|
||||
## B. Architecture
|
||||
|
||||
### Main Processes & Separation of Concerns
|
||||
1. **Electron Main Process (`electron/`)**:
|
||||
- `electron/main.ts`: Application lifecycle, frameless custom window with draggable titlebar, navigation security guard, IPC route registration.
|
||||
- `electron/logger.ts`: Redacting logger that automatically strips tokens, basic auth credentials, and passwords from logs and disk output.
|
||||
- `electron/persistence/database.ts`: SQLite database with WAL mode for repository records, pinned status, graph coordinates, and UI state.
|
||||
- `electron/settings/settings-store.ts`: Typed configuration store using `electron-store`.
|
||||
2. **Git Execution Architecture (`electron/git/`)**:
|
||||
- `electron/git/executor.ts`: Spawns Git using argv arrays (`child_process.spawn`) — **NEVER** constructs shell strings. Sanitizes command logging to redact credential URLs.
|
||||
- `electron/git/detector.ts`: Multi-stage detector testing custom settings paths, system PATH, and standard Windows 64-bit and 32-bit install locations.
|
||||
- `electron/git/changes.ts`: Porcelain-v1 status parser, staging/unstaging, file discard, and partial index patch application.
|
||||
- `electron/git/diff.ts`: Unified diff parsing with hunk analysis, additions/deletions counts, binary file detection, and size limits.
|
||||
- `electron/git/history.ts`: Full commit log reader, detail parser with diff-tree stats, and topological column-lane graph DAG generator.
|
||||
- `electron/git/branches.ts`: Branch tracking with upstream sync, ahead/behind indicators, checkout, merge, rebase, and tag management.
|
||||
- `electron/git/remote.ts`: Fetch, pull, push with network error classification.
|
||||
- `electron/git/stash.ts`: Stash listing, creation with untracked files, apply, pop, and drop.
|
||||
- `electron/git/conflict.ts`: Conflict detection, 3-way show extraction (`:2:` and `:3:`), and continue/abort workflow.
|
||||
- `electron/git/blame.ts`: Porcelain blame parser.
|
||||
3. **IPC Contracts & Security Boundaries (`shared/` & `electron/preload.ts`)**:
|
||||
- `shared/types/ipc.types.ts`: Defines `FlowGitApi` and `IpcResult<T>`.
|
||||
- `electron/preload.ts`: Narrow context isolation bridge exposing typed `window.flowgit` object to the renderer. Renderer has Node.js integration disabled and context isolation enabled.
|
||||
|
||||
---
|
||||
|
||||
## C. Design Requirements (DESIGN.md Status: ✅ VERIFIED)
|
||||
|
||||
- **Palette**: Dark only. Canvas Void `#000000`, primary card surface Carbon `#111111`, elevated card Graphite `#191919`, hairline dividers Iron `#202020`, borders Slate `#3a3a3a`, primary text Paper `#eeeeee`, secondary text Fog `#b4b4b4`, tertiary Ash `#7e7e7e` and Steel `#606060`.
|
||||
- **Ember Accent (`#da5c2c`)**: Strictly reserved for primary CTAs, active selection indicators, and key graph highlights.
|
||||
- **Typography**: Monospaced hierarchy using BerkeleyMono / JetBrains Mono fallback for all navigational headers, cards, tables, buttons, and diffs.
|
||||
- **Radius**: `2px` for all panels, cards, inputs, buttons; `9999px` for pill tags and badges.
|
||||
- **Blueprint Graph Visuals**: Dark `#000000` canvas with subtle `#191919` dot grid, node cards styled in Carbon `#111111` with Iron `#202020` borders, branch color bars, Bezier curves connecting parent/child commits, minimap viewport navigator, search and zoom-to-fit controls.
|
||||
|
||||
---
|
||||
|
||||
## D. Implemented Features
|
||||
|
||||
| Feature | Status | Location | Tested |
|
||||
|---|---|---|---|
|
||||
| Frameless App Shell & TitleBar | Fully Functional | `electron/main.ts`, `src/components/TitleBar.tsx` | Yes (Build & Typecheck) |
|
||||
| Git Detection & Fallback | Fully Functional | `electron/git/detector.ts` | Yes (Vitest verified) |
|
||||
| Safe Git Process Spawner | Fully Functional | `electron/git/executor.ts` | Yes (Vitest verified) |
|
||||
| Repository Open / Init / Clone | Fully Functional | `electron/ipc/repo.ipc.ts`, `src/views/Welcome/WelcomeView.tsx` | Yes (Vitest & Build) |
|
||||
| SQLite Repo Persistence | Fully Functional | `electron/persistence/database.ts` | Yes (Build) |
|
||||
| Changes & Staging View | Fully Functional | `electron/git/changes.ts`, `src/views/Repository/Changes/ChangesView.tsx` | Yes (Vitest verified) |
|
||||
| Commit Creation & Amend | Fully Functional | `electron/git/changes.ts`, `src/views/Repository/Changes/ChangesView.tsx` | Yes (Vitest verified) |
|
||||
| Unified / Side-by-Side Diff | Fully Functional | `electron/git/diff.ts`, `src/views/Repository/Changes/ChangesView.tsx` | Yes (Vitest verified) |
|
||||
| Commit History List & Details | Fully Functional | `electron/git/history.ts`, `src/views/Repository/History/HistoryView.tsx` | Yes (Vitest verified) |
|
||||
| Blueprint Git Graph (SVG/DAG) | Fully Functional | `electron/git/history.ts`, `src/views/Repository/Graph/GraphView.tsx` | Yes (Vitest verified) |
|
||||
| Branch Management (Create/Switch/Delete) | Fully Functional | `electron/git/branches.ts`, `src/views/Repository/Branches/BranchesView.tsx` | Yes (Vitest verified) |
|
||||
| Tag Management | Fully Functional | `electron/git/branches.ts`, `src/views/Repository/Branches/BranchesView.tsx` | Yes (Vitest verified) |
|
||||
| Stash Management | Fully Functional | `electron/git/stash.ts`, `src/views/Repository/Stash/StashView.tsx` | Yes (Vitest verified) |
|
||||
| Remote Fetch / Pull / Push | Fully Functional | `electron/git/remote.ts`, `src/views/Repository/Remotes/RemotesView.tsx` | Yes (Vitest verified) |
|
||||
| Repository File Browser | Fully Functional | `src/views/Repository/FileBrowser/FileBrowserView.tsx` | Yes (Build & Typecheck) |
|
||||
| Application Settings View | Fully Functional | `electron/settings/settings-store.ts`, `src/views/Settings/SettingsView.tsx` | Yes (Build & Typecheck) |
|
||||
| Confirm Modal & Toasts | Fully Functional | `src/components/ConfirmModal.tsx`, `src/components/ToastContainer.tsx` | Yes (Build & Typecheck) |
|
||||
|
||||
---
|
||||
|
||||
## E. Current Task
|
||||
|
||||
**Current Phase:** Transitioning from Phase 3 into Phase 4 / Phase 5.
|
||||
**Intended Outcome:** All core views and build pipelines are established, compiling cleanly with 0 TypeScript errors and passing 100% of automated tests.
|
||||
|
||||
---
|
||||
|
||||
## F. Next Actions
|
||||
|
||||
### P1 — Core Expansion
|
||||
- [ ] Connect Monaco Editor for syntax-highlighted three-way merge conflict editing.
|
||||
- [ ] Implement integrated interactive terminal session with `xterm.js` and node-pty/child-process.
|
||||
- [ ] Add GitHub / GitLab / Gitea remote REST/GraphQL provider API authentication and PR list browsing.
|
||||
|
||||
### P2 — Windows Packaging & Polish
|
||||
- [ ] Package Windows distribution installer using `electron-builder` (`npm run dist`).
|
||||
- [ ] Generate standard FlowGit `.ico` icon for Windows taskbar and installer.
|
||||
|
||||
---
|
||||
|
||||
## G. Testing and Build Status
|
||||
|
||||
| Command | Execution Status | Result |
|
||||
|---|---|---|
|
||||
| `npm run test` (Vitest) | Executed | **9 of 9 passed (100%)** |
|
||||
| `npm run typecheck` (`tsc --noEmit`) | Executed | **0 errors, clean exit** |
|
||||
| `npm run build` (`electron-vite build`) | Executed | **Production bundle built in 918ms** |
|
||||
|
||||
---
|
||||
|
||||
## H. Instructions for the Next AI Agent
|
||||
|
||||
1. **Read `PROJECT_STATE.md` completely before making changes.**
|
||||
2. **Read `DESIGN.md` before modifying any UI.** Adhere strictly to the Axiom design system.
|
||||
3. **Inspect the actual source code and Git status** rather than assuming unverified facts.
|
||||
4. **Preserve existing architectural patterns**: All Git interactions must use argv arrays in `executor.ts`, never shell string interpolation.
|
||||
5. **Run `npm run test` and `npm run typecheck`** before and after changes.
|
||||
6. **Update `PROJECT_STATE.md`** whenever status changes.
|
||||
54
electron.vite.config.ts
Normal file
54
electron.vite.config.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': resolve('shared'),
|
||||
'@electron': resolve('electron')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve('electron/main.ts')
|
||||
},
|
||||
rollupOptions: {
|
||||
external: []
|
||||
}
|
||||
}
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@shared': resolve('shared')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve('electron/preload.ts')
|
||||
}
|
||||
}
|
||||
},
|
||||
renderer: {
|
||||
root: resolve('src'),
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve('src'),
|
||||
'@shared': resolve('shared')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/index.html')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
64
electron/git/blame.ts
Normal file
64
electron/git/blame.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { execGitOrThrow } from './executor'
|
||||
import type { BlameEntry } from '../../shared/types/git.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('git:blame')
|
||||
|
||||
export async function blameFile(
|
||||
repoPath: string,
|
||||
filePath: string,
|
||||
ref = 'HEAD'
|
||||
): Promise<BlameEntry[]> {
|
||||
const result = await execGitOrThrow(
|
||||
['blame', '--line-porcelain', ref, '--', filePath],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
|
||||
const entries: BlameEntry[] = []
|
||||
const lines = result.stdout.split('\n')
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const headerLine = lines[i]
|
||||
if (!headerLine || headerLine.startsWith('\t')) { i++; continue }
|
||||
|
||||
const headerMatch = headerLine.match(/^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/)
|
||||
if (!headerMatch) { i++; continue }
|
||||
|
||||
const hash = headerMatch[1]
|
||||
const lineNumber = parseInt(headerMatch[2], 10)
|
||||
|
||||
i++
|
||||
|
||||
let author = ''
|
||||
let authorEmail = ''
|
||||
let date = ''
|
||||
let message = ''
|
||||
|
||||
while (i < lines.length && !lines[i].startsWith('\t')) {
|
||||
const metaLine = lines[i]
|
||||
if (metaLine.startsWith('author ')) author = metaLine.slice(7)
|
||||
else if (metaLine.startsWith('author-mail ')) authorEmail = metaLine.slice(12).replace(/[<>]/g, '')
|
||||
else if (metaLine.startsWith('author-time ')) date = new Date(parseInt(metaLine.slice(12), 10) * 1000).toISOString()
|
||||
else if (metaLine.startsWith('summary ')) message = metaLine.slice(8)
|
||||
i++
|
||||
}
|
||||
|
||||
// The line content starts with \t
|
||||
const lineContent = lines[i]?.startsWith('\t') ? lines[i].slice(1) : ''
|
||||
i++
|
||||
|
||||
entries.push({
|
||||
hash,
|
||||
shortHash: hash.slice(0, 7),
|
||||
author,
|
||||
authorEmail,
|
||||
date,
|
||||
message,
|
||||
lineNumber,
|
||||
lineContent,
|
||||
})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
185
electron/git/branches.ts
Normal file
185
electron/git/branches.ts
Normal file
@ -0,0 +1,185 @@
|
||||
import { execGit, execGitOrThrow } from './executor'
|
||||
import type { Branch, Tag, Remote } from '../../shared/types/git.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('git:branches')
|
||||
|
||||
/**
|
||||
* List all branches (local + remote) with tracking info.
|
||||
*/
|
||||
export async function listBranches(repoPath: string): Promise<Branch[]> {
|
||||
const result = await execGit(
|
||||
['branch', '-a', '--format=%(refname:short)%09%(HEAD)%09%(upstream:short)%09%(upstream:track)%09%(objectname:short)%09%(subject)'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
if (result.exitCode !== 0) return []
|
||||
|
||||
const branches: Branch[] = []
|
||||
const lines = result.stdout.trim().split('\n').filter(Boolean)
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split('\t')
|
||||
if (parts.length < 5) continue
|
||||
|
||||
const [fullName, headMarker, upstream, trackInfo, shortHash, ...messageParts] = parts
|
||||
const message = messageParts ? messageParts.join('\t') : ''
|
||||
const isCurrent = headMarker === '*'
|
||||
const isRemote = fullName.startsWith('remotes/')
|
||||
const name = isRemote ? fullName.replace(/^remotes\//, '') : fullName
|
||||
|
||||
const aheadMatch = (trackInfo ?? '').match(/ahead (\d+)/)
|
||||
const behindMatch = (trackInfo ?? '').match(/behind (\d+)/)
|
||||
const ahead = aheadMatch ? parseInt(aheadMatch[1], 10) : 0
|
||||
const behind = behindMatch ? parseInt(behindMatch[1], 10) : 0
|
||||
|
||||
branches.push({
|
||||
name,
|
||||
fullName,
|
||||
isRemote,
|
||||
isCurrent: isCurrent && !isRemote,
|
||||
upstream: upstream || undefined,
|
||||
ahead,
|
||||
behind,
|
||||
headCommit: shortHash ?? '',
|
||||
headMessage: message ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
return branches
|
||||
}
|
||||
|
||||
/** Create a branch. */
|
||||
export async function createBranch(repoPath: string, name: string, from?: string): Promise<void> {
|
||||
const args = ['checkout', '-b', name]
|
||||
if (from) args.push(from)
|
||||
await execGitOrThrow(args, { cwd: repoPath })
|
||||
}
|
||||
|
||||
/** Rename a branch. */
|
||||
export async function renameBranch(repoPath: string, oldName: string, newName: string): Promise<void> {
|
||||
await execGitOrThrow(['branch', '-m', oldName, newName], { cwd: repoPath })
|
||||
}
|
||||
|
||||
/** Switch branches. */
|
||||
export async function switchBranch(repoPath: string, name: string): Promise<void> {
|
||||
// Try new git switch first, fallback to checkout
|
||||
const result = await execGit(['switch', name], { cwd: repoPath })
|
||||
if (result.exitCode !== 0) {
|
||||
await execGitOrThrow(['checkout', name], { cwd: repoPath })
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a branch. */
|
||||
export async function deleteBranch(repoPath: string, name: string, force = false): Promise<void> {
|
||||
const flag = force ? '-D' : '-d'
|
||||
await execGitOrThrow(['branch', flag, name], { cwd: repoPath })
|
||||
}
|
||||
|
||||
/** Merge a branch into the current branch. */
|
||||
export async function mergeBranch(
|
||||
repoPath: string,
|
||||
source: string,
|
||||
options: { strategy?: string; noFF?: boolean } = {}
|
||||
): Promise<{ success: boolean; conflicts: boolean }> {
|
||||
const args = ['merge']
|
||||
if (options.noFF) args.push('--no-ff')
|
||||
if (options.strategy) args.push(`--strategy=${options.strategy}`)
|
||||
args.push(source)
|
||||
|
||||
const result = await execGit(args, { cwd: repoPath })
|
||||
if (result.exitCode === 0) return { success: true, conflicts: false }
|
||||
|
||||
// Check for conflicts
|
||||
if (result.stdout.includes('CONFLICT') || result.stderr.includes('conflict')) {
|
||||
return { success: false, conflicts: true }
|
||||
}
|
||||
|
||||
throw new Error(result.stderr || result.stdout)
|
||||
}
|
||||
|
||||
/** Rebase current branch onto another. */
|
||||
export async function rebaseBranch(repoPath: string, onto: string): Promise<{ success: boolean; conflicts: boolean }> {
|
||||
const result = await execGit(['rebase', onto], { cwd: repoPath })
|
||||
if (result.exitCode === 0) return { success: true, conflicts: false }
|
||||
if (result.stdout.includes('CONFLICT') || result.stderr.includes('conflict')) {
|
||||
return { success: false, conflicts: true }
|
||||
}
|
||||
throw new Error(result.stderr || result.stdout)
|
||||
}
|
||||
|
||||
/** List tags. */
|
||||
export async function listTags(repoPath: string): Promise<Tag[]> {
|
||||
const result = await execGit(
|
||||
['tag', '-l', '--sort=-creatordate', '--format=%(refname:short)%09%(objectname:short)%09%(*objectname:short)%09%(subject)%09%(creatordate:iso)%09%(taggername)%09%(taggeremail)'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
if (result.exitCode !== 0) return []
|
||||
|
||||
return result.stdout.trim().split('\n').filter(Boolean).map((line) => {
|
||||
const parts = line.split('\t')
|
||||
const isAnnotated = Boolean(parts[2])
|
||||
return {
|
||||
name: parts[0],
|
||||
hash: parts[2] || parts[1],
|
||||
tagHash: parts[2] ? parts[1] : undefined,
|
||||
message: parts[3],
|
||||
isAnnotated,
|
||||
date: parts[4],
|
||||
taggerName: parts[5] || undefined,
|
||||
taggerEmail: parts[6] || undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Create a tag. */
|
||||
export async function createTag(
|
||||
repoPath: string,
|
||||
name: string,
|
||||
ref: string,
|
||||
message?: string
|
||||
): Promise<void> {
|
||||
if (message) {
|
||||
await execGitOrThrow(['tag', '-a', name, ref, '-m', message], { cwd: repoPath })
|
||||
} else {
|
||||
await execGitOrThrow(['tag', name, ref], { cwd: repoPath })
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a tag. */
|
||||
export async function deleteTag(repoPath: string, name: string): Promise<void> {
|
||||
await execGitOrThrow(['tag', '-d', name], { cwd: repoPath })
|
||||
}
|
||||
|
||||
/** Push a tag to a remote. */
|
||||
export async function pushTag(repoPath: string, remote: string, tagName: string): Promise<void> {
|
||||
await execGitOrThrow(['push', remote, `refs/tags/${tagName}`], { cwd: repoPath })
|
||||
}
|
||||
|
||||
/** List remotes. */
|
||||
export async function listRemotes(repoPath: string): Promise<Remote[]> {
|
||||
const result = await execGit(['remote', '-v'], { cwd: repoPath })
|
||||
if (result.exitCode !== 0) return []
|
||||
|
||||
const remotes = new Map<string, Remote>()
|
||||
for (const line of result.stdout.trim().split('\n').filter(Boolean)) {
|
||||
const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/)
|
||||
if (!match) continue
|
||||
const [, name, url, type] = match
|
||||
if (!remotes.has(name)) remotes.set(name, { name, fetchUrl: '', pushUrl: '' })
|
||||
const r = remotes.get(name)!
|
||||
if (type === 'fetch') r.fetchUrl = url
|
||||
else r.pushUrl = url
|
||||
}
|
||||
|
||||
return Array.from(remotes.values())
|
||||
}
|
||||
|
||||
/** Add a remote. */
|
||||
export async function addRemote(repoPath: string, name: string, url: string): Promise<void> {
|
||||
await execGitOrThrow(['remote', 'add', name, url], { cwd: repoPath })
|
||||
}
|
||||
|
||||
/** Remove a remote. */
|
||||
export async function removeRemote(repoPath: string, name: string): Promise<void> {
|
||||
await execGitOrThrow(['remote', 'remove', name], { cwd: repoPath })
|
||||
}
|
||||
173
electron/git/changes.ts
Normal file
173
electron/git/changes.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import { execGit, execGitOrThrow } from './executor'
|
||||
import type { RepoStatus, ChangedFile, FileStatus } from '../../shared/types/git.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('git:changes')
|
||||
|
||||
/**
|
||||
* Parse git status --porcelain=v1 output into structured ChangedFile objects.
|
||||
* XY format: X = index status, Y = work tree status
|
||||
*/
|
||||
export function parseStatus(output: string): ChangedFile[] {
|
||||
const files: ChangedFile[] = []
|
||||
const lines = output.split('\n').filter((l) => l.length > 0)
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.length < 3) continue
|
||||
|
||||
const x = line[0] // index (staged) status
|
||||
const y = line[1] // working tree (unstaged) status
|
||||
let path = line.slice(3)
|
||||
let originalPath: string | undefined
|
||||
|
||||
// Handle rename/copy: "R old -> new" or "R100 old\0new"
|
||||
if (path.includes(' -> ')) {
|
||||
const parts = path.split(' -> ')
|
||||
originalPath = parts[0].trim()
|
||||
path = parts[1].trim()
|
||||
}
|
||||
|
||||
// Remove surrounding quotes (git adds them for special chars)
|
||||
if (path.startsWith('"') && path.endsWith('"')) {
|
||||
path = path.slice(1, -1)
|
||||
}
|
||||
|
||||
const staged = x !== ' ' && x !== '?'
|
||||
const unstaged = y !== ' ' && y !== '?'
|
||||
const isUntracked = x === '?' && y === '?'
|
||||
const isConflicted = (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D'))
|
||||
|
||||
files.push({
|
||||
path,
|
||||
originalPath,
|
||||
status: getFileStatus(x, y),
|
||||
staged,
|
||||
unstaged,
|
||||
indexStatus: x,
|
||||
workingStatus: y,
|
||||
})
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function getFileStatus(x: string, y: string): FileStatus {
|
||||
if (x === '?' && y === '?') return 'untracked'
|
||||
if (x === '!' && y === '!') return 'ignored'
|
||||
if (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) return 'conflicted'
|
||||
if (x === 'D' || y === 'D') return 'deleted'
|
||||
if (x === 'R' || y === 'R') return 'renamed'
|
||||
if (x === 'C' || y === 'C') return 'copied'
|
||||
if (x === 'A') return 'added'
|
||||
if (x === 'M' || y === 'M') return 'modified'
|
||||
return 'modified'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full repository status including branch info.
|
||||
*/
|
||||
export async function getRepoStatus(repoPath: string): Promise<RepoStatus> {
|
||||
const [statusResult, branchResult] = await Promise.all([
|
||||
execGit(['status', '--porcelain=v1', '-u', '--renames'], { cwd: repoPath }),
|
||||
execGit(['status', '--branch', '--porcelain=v1'], { cwd: repoPath }),
|
||||
])
|
||||
|
||||
// Parse branch info from first line of --branch output
|
||||
let branch: string | null = null
|
||||
let detachedHead = false
|
||||
let ahead = 0
|
||||
let behind = 0
|
||||
|
||||
const branchLines = branchResult.stdout.split('\n')
|
||||
const branchLine = branchLines[0] ?? ''
|
||||
|
||||
if (branchLine.startsWith('## HEAD (no branch)')) {
|
||||
detachedHead = true
|
||||
} else if (branchLine.startsWith('## ')) {
|
||||
let branchInfo = branchLine.slice(3).trim()
|
||||
if (branchInfo.startsWith('No commits yet on ')) {
|
||||
branchInfo = branchInfo.replace('No commits yet on ', '')
|
||||
}
|
||||
const aheadMatch = branchInfo.match(/\[ahead (\d+)/)
|
||||
const behindMatch = branchInfo.match(/behind (\d+)/)
|
||||
if (aheadMatch) ahead = parseInt(aheadMatch[1], 10)
|
||||
if (behindMatch) behind = parseInt(behindMatch[1], 10)
|
||||
branch = branchInfo.split('...')[0].split(' [')[0].trim()
|
||||
}
|
||||
|
||||
const headResult = await execGit(['rev-parse', 'HEAD'], { cwd: repoPath })
|
||||
const headCommit = headResult.exitCode === 0 ? headResult.stdout.trim() : null
|
||||
|
||||
const files = parseStatus(statusResult.stdout)
|
||||
|
||||
// Detect in-progress operations
|
||||
const mergeInProgress = (await execGit(['rev-parse', '--verify', 'MERGE_HEAD'], { cwd: repoPath })).exitCode === 0
|
||||
const rebaseInProgress = (await execGit(['rev-parse', '--verify', 'REBASE_HEAD'], { cwd: repoPath })).exitCode === 0
|
||||
const cherryPickInProgress = (await execGit(['rev-parse', '--verify', 'CHERRY_PICK_HEAD'], { cwd: repoPath })).exitCode === 0
|
||||
const revertInProgress = (await execGit(['rev-parse', '--verify', 'REVERT_HEAD'], { cwd: repoPath })).exitCode === 0
|
||||
|
||||
return {
|
||||
branch,
|
||||
detachedHead,
|
||||
headCommit,
|
||||
ahead,
|
||||
behind,
|
||||
staged: files.filter((f) => f.staged && f.status !== 'conflicted'),
|
||||
unstaged: files.filter((f) => f.unstaged && !f.staged && f.status !== 'untracked' && f.status !== 'conflicted'),
|
||||
untracked: files.filter((f) => f.status === 'untracked'),
|
||||
conflicted: files.filter((f) => f.status === 'conflicted'),
|
||||
mergeInProgress,
|
||||
rebaseInProgress,
|
||||
cherryPickInProgress,
|
||||
revertInProgress,
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage a file. */
|
||||
export async function stageFile(repoPath: string, filePath: string): Promise<void> {
|
||||
await execGitOrThrow(['add', '--', filePath], { cwd: repoPath })
|
||||
}
|
||||
|
||||
/** Unstage a file. */
|
||||
export async function unstageFile(repoPath: string, filePath: string): Promise<void> {
|
||||
const result = await execGit(['restore', '--staged', '--', filePath], { cwd: repoPath })
|
||||
if (result.exitCode !== 0) {
|
||||
// Fallback for older git: reset HEAD
|
||||
await execGitOrThrow(['reset', 'HEAD', '--', filePath], { cwd: repoPath })
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage all changes. */
|
||||
export async function stageAll(repoPath: string): Promise<void> {
|
||||
await execGitOrThrow(['add', '-A'], { cwd: repoPath })
|
||||
}
|
||||
|
||||
/** Unstage all changes. */
|
||||
export async function unstageAll(repoPath: string): Promise<void> {
|
||||
const result = await execGit(['restore', '--staged', '.'], { cwd: repoPath })
|
||||
if (result.exitCode !== 0) {
|
||||
await execGitOrThrow(['reset', 'HEAD', '.'], { cwd: repoPath })
|
||||
}
|
||||
}
|
||||
|
||||
/** Discard changes to a file (restore working tree). */
|
||||
export async function discardFile(repoPath: string, filePath: string): Promise<void> {
|
||||
const result = await execGit(['restore', '--', filePath], { cwd: repoPath })
|
||||
if (result.exitCode !== 0) {
|
||||
await execGitOrThrow(['checkout', 'HEAD', '--', filePath], { cwd: repoPath })
|
||||
}
|
||||
}
|
||||
|
||||
/** Discard all working tree changes. */
|
||||
export async function discardAll(repoPath: string): Promise<void> {
|
||||
await execGitOrThrow(['restore', '.'], { cwd: repoPath })
|
||||
// Also clean untracked files? We'll ask in UI first.
|
||||
}
|
||||
|
||||
/** Apply a patch to the index (for partial staging). */
|
||||
export async function applyPatch(repoPath: string, patch: string, reverse = false): Promise<void> {
|
||||
const args = ['apply', '--cached', '--whitespace=nowarn']
|
||||
if (reverse) args.push('--reverse')
|
||||
args.push('-')
|
||||
await execGitOrThrow(args, { cwd: repoPath, stdin: patch })
|
||||
}
|
||||
93
electron/git/conflict.ts
Normal file
93
electron/git/conflict.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import { execGit, execGitOrThrow } from './executor'
|
||||
import type { ConflictFile, BlameEntry } from '../../shared/types/git.types'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('git:conflict')
|
||||
|
||||
/** List files with unresolved merge conflicts. */
|
||||
export async function listConflicts(repoPath: string): Promise<ConflictFile[]> {
|
||||
const result = await execGit(['diff', '--name-only', '--diff-filter=U'], { cwd: repoPath })
|
||||
if (result.exitCode !== 0 || !result.stdout.trim()) return []
|
||||
|
||||
const conflictFiles: ConflictFile[] = []
|
||||
const paths = result.stdout.trim().split('\n').filter(Boolean)
|
||||
|
||||
for (const filePath of paths) {
|
||||
try {
|
||||
const content = await readFile(join(repoPath, filePath), 'utf8')
|
||||
const conflictMarkers = (content.match(/^<<<<<<< /gm) ?? []).length
|
||||
|
||||
// Extract ours and theirs versions
|
||||
const oursResult = await execGit(['show', `:2:${filePath}`], { cwd: repoPath })
|
||||
const theirsResult = await execGit(['show', `:3:${filePath}`], { cwd: repoPath })
|
||||
|
||||
conflictFiles.push({
|
||||
path: filePath,
|
||||
ours: oursResult.stdout,
|
||||
theirs: theirsResult.stdout,
|
||||
conflictCount: conflictMarkers,
|
||||
})
|
||||
} catch {
|
||||
conflictFiles.push({ path: filePath, ours: '', theirs: '', conflictCount: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
return conflictFiles
|
||||
}
|
||||
|
||||
/** Mark a file as resolved (git add). */
|
||||
export async function markResolved(repoPath: string, filePath: string): Promise<void> {
|
||||
await execGitOrThrow(['add', '--', filePath], { cwd: repoPath })
|
||||
}
|
||||
|
||||
/** Continue a merge/rebase/cherry-pick after resolving conflicts. */
|
||||
export async function continueOperation(repoPath: string): Promise<void> {
|
||||
// Detect operation type
|
||||
const mergeHead = await execGit(['rev-parse', '--verify', 'MERGE_HEAD'], { cwd: repoPath })
|
||||
if (mergeHead.exitCode === 0) {
|
||||
await execGitOrThrow(['commit', '--no-edit'], { cwd: repoPath })
|
||||
return
|
||||
}
|
||||
|
||||
const rebaseHead = await execGit(['rev-parse', '--verify', 'REBASE_HEAD'], { cwd: repoPath })
|
||||
if (rebaseHead.exitCode === 0) {
|
||||
await execGitOrThrow(['rebase', '--continue'], {
|
||||
cwd: repoPath,
|
||||
env: { GIT_EDITOR: 'true' },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const cherryPickHead = await execGit(['rev-parse', '--verify', 'CHERRY_PICK_HEAD'], { cwd: repoPath })
|
||||
if (cherryPickHead.exitCode === 0) {
|
||||
await execGitOrThrow(['cherry-pick', '--continue'], { cwd: repoPath })
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('No merge, rebase, or cherry-pick in progress')
|
||||
}
|
||||
|
||||
/** Abort a merge/rebase/cherry-pick. */
|
||||
export async function abortOperation(repoPath: string): Promise<void> {
|
||||
const mergeHead = await execGit(['rev-parse', '--verify', 'MERGE_HEAD'], { cwd: repoPath })
|
||||
if (mergeHead.exitCode === 0) {
|
||||
await execGitOrThrow(['merge', '--abort'], { cwd: repoPath })
|
||||
return
|
||||
}
|
||||
|
||||
const rebaseHead = await execGit(['rev-parse', '--verify', 'REBASE_HEAD'], { cwd: repoPath })
|
||||
if (rebaseHead.exitCode === 0) {
|
||||
await execGitOrThrow(['rebase', '--abort'], { cwd: repoPath })
|
||||
return
|
||||
}
|
||||
|
||||
const cherryPickHead = await execGit(['rev-parse', '--verify', 'CHERRY_PICK_HEAD'], { cwd: repoPath })
|
||||
if (cherryPickHead.exitCode === 0) {
|
||||
await execGitOrThrow(['cherry-pick', '--abort'], { cwd: repoPath })
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error('No operation to abort')
|
||||
}
|
||||
119
electron/git/detector.ts
Normal file
119
electron/git/detector.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { existsSync } from 'fs'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('git:detector')
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let cachedGitPath: string | null = null
|
||||
let detectionDone = false
|
||||
|
||||
const COMMON_GIT_PATHS = [
|
||||
'C:\\Program Files\\Git\\bin\\git.exe',
|
||||
'C:\\Program Files\\Git\\cmd\\git.exe',
|
||||
'C:\\Program Files (x86)\\Git\\bin\\git.exe',
|
||||
'C:\\Program Files (x86)\\Git\\cmd\\git.exe',
|
||||
]
|
||||
|
||||
export interface GitDetectionResult {
|
||||
found: boolean
|
||||
path: string | null
|
||||
version: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the Git executable. Checks:
|
||||
* 1. Cached result from previous detection
|
||||
* 2. Custom path from settings
|
||||
* 3. `git` on PATH
|
||||
* 4. Common Windows installation locations
|
||||
*/
|
||||
export async function detectGit(customPath?: string | null): Promise<GitDetectionResult> {
|
||||
// Try custom path first
|
||||
if (customPath) {
|
||||
if (existsSync(customPath)) {
|
||||
const version = await getGitVersion(customPath)
|
||||
if (version) {
|
||||
cachedGitPath = customPath
|
||||
detectionDone = true
|
||||
logger.info(`Git found at custom path: ${customPath} (${version})`)
|
||||
return { found: true, path: customPath, version }
|
||||
}
|
||||
}
|
||||
return { found: false, path: null, version: null, error: `Git not found at: ${customPath}` }
|
||||
}
|
||||
|
||||
// Try PATH
|
||||
const pathVersion = await getGitVersion('git')
|
||||
if (pathVersion) {
|
||||
const resolvedPath = await resolveGitPath()
|
||||
cachedGitPath = resolvedPath ?? 'git'
|
||||
detectionDone = true
|
||||
logger.info(`Git found on PATH (${pathVersion})`)
|
||||
return { found: true, path: cachedGitPath, version: pathVersion }
|
||||
}
|
||||
|
||||
// Try common Windows paths
|
||||
for (const gitPath of COMMON_GIT_PATHS) {
|
||||
if (existsSync(gitPath)) {
|
||||
const version = await getGitVersion(gitPath)
|
||||
if (version) {
|
||||
cachedGitPath = gitPath
|
||||
detectionDone = true
|
||||
logger.info(`Git found at: ${gitPath} (${version})`)
|
||||
return { found: true, path: gitPath, version }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn('Git not found on system')
|
||||
return {
|
||||
found: false,
|
||||
path: null,
|
||||
version: null,
|
||||
error: 'Git is not installed or not in PATH. Please install Git for Windows from https://git-scm.com/'
|
||||
}
|
||||
}
|
||||
|
||||
async function getGitVersion(gitPath: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(gitPath, ['--version'], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
})
|
||||
const match = stdout.trim().match(/git version (.+)/)
|
||||
return match ? match[1] : stdout.trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGitPath(): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('where', ['git'], {
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
const paths = stdout.trim().split('\r\n').filter(Boolean)
|
||||
return paths[0] ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached Git executable path. Returns null if not yet detected.
|
||||
* Call detectGit() first.
|
||||
*/
|
||||
export function getGitExecutable(): string | null {
|
||||
return cachedGitPath ?? 'git'
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a custom Git path (called from settings).
|
||||
*/
|
||||
export function setGitPath(path: string | null): void {
|
||||
cachedGitPath = path
|
||||
}
|
||||
203
electron/git/diff.ts
Normal file
203
electron/git/diff.ts
Normal file
@ -0,0 +1,203 @@
|
||||
import { execGit, execGitOrThrow } from './executor'
|
||||
import type { FileDiff, DiffHunk, DiffLine } from '../../shared/types/git.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('git:diff')
|
||||
|
||||
const LARGE_FILE_THRESHOLD = 500 * 1024 // 500 KB of diff output
|
||||
|
||||
export interface DiffOptions {
|
||||
staged?: boolean // diff against index
|
||||
ref?: string // compare against a specific ref
|
||||
ignoreWhitespace?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff for a single file.
|
||||
*/
|
||||
export async function getFileDiff(
|
||||
repoPath: string,
|
||||
filePath: string,
|
||||
options: DiffOptions = {}
|
||||
): Promise<FileDiff> {
|
||||
const args = ['diff', '--unified=3', '-M', '-C', '--no-color']
|
||||
|
||||
if (options.ignoreWhitespace) args.push('-w')
|
||||
|
||||
if (options.staged) {
|
||||
args.push('--cached')
|
||||
if (options.ref) args.push(options.ref)
|
||||
} else if (options.ref) {
|
||||
args.push(options.ref)
|
||||
}
|
||||
|
||||
args.push('--', filePath)
|
||||
|
||||
const result = await execGit(args, { cwd: repoPath })
|
||||
|
||||
if (result.stdout.length > LARGE_FILE_THRESHOLD) {
|
||||
return {
|
||||
oldPath: filePath,
|
||||
newPath: filePath,
|
||||
status: 'modified',
|
||||
hunks: [],
|
||||
isBinary: false,
|
||||
isLarge: true,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseDiff(result.stdout)
|
||||
return parsed[0] ?? {
|
||||
oldPath: filePath,
|
||||
newPath: filePath,
|
||||
status: 'modified',
|
||||
hunks: [],
|
||||
isBinary: false,
|
||||
isLarge: false,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff for a commit (vs its first parent).
|
||||
*/
|
||||
export async function getCommitDiff(repoPath: string, hash: string): Promise<FileDiff[]> {
|
||||
const args = ['diff', '--unified=3', '-M', '-C', '--no-color', `${hash}^`, hash, '--']
|
||||
const result = await execGit(args, { cwd: repoPath })
|
||||
if (result.exitCode !== 0 && result.stderr.includes('unknown revision')) {
|
||||
// Initial commit: compare to empty tree
|
||||
const emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
|
||||
const r2 = await execGit(
|
||||
['diff', '--unified=3', '-M', '-C', '--no-color', emptyTree, hash, '--'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
return parseDiff(r2.stdout)
|
||||
}
|
||||
return parseDiff(result.stdout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff between two commits.
|
||||
*/
|
||||
export async function getCommitsDiff(
|
||||
repoPath: string,
|
||||
fromHash: string,
|
||||
toHash: string
|
||||
): Promise<FileDiff[]> {
|
||||
const result = await execGitOrThrow(
|
||||
['diff', '--unified=3', '-M', '-C', '--no-color', fromHash, toHash, '--'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
return parseDiff(result.stdout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff between two branches.
|
||||
*/
|
||||
export async function getBranchDiff(
|
||||
repoPath: string,
|
||||
branchA: string,
|
||||
branchB: string
|
||||
): Promise<FileDiff[]> {
|
||||
const result = await execGitOrThrow(
|
||||
['diff', '--unified=3', '-M', '-C', '--no-color', branchA, branchB, '--'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
return parseDiff(result.stdout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unified diff output into structured FileDiff objects.
|
||||
*/
|
||||
export function parseDiff(rawDiff: string): FileDiff[] {
|
||||
const files: FileDiff[] = []
|
||||
if (!rawDiff.trim()) return files
|
||||
|
||||
// Split by 'diff --git' header
|
||||
const sections = rawDiff.split(/^(?=diff --git )/m).filter(Boolean)
|
||||
|
||||
for (const section of sections) {
|
||||
const lines = section.split('\n')
|
||||
let i = 0
|
||||
|
||||
// Parse diff --git a/... b/...
|
||||
const headerLine = lines[i++]
|
||||
const headerMatch = headerLine.match(/^diff --git a\/(.*?) b\/(.*)$/)
|
||||
if (!headerMatch) continue
|
||||
|
||||
let oldPath = headerMatch[1]
|
||||
let newPath = headerMatch[2]
|
||||
let isBinary = false
|
||||
let status: FileDiff['status'] = 'modified'
|
||||
|
||||
// Parse extended headers
|
||||
while (i < lines.length && !lines[i].startsWith('---') && !lines[i].startsWith('@@ ')) {
|
||||
const line = lines[i]
|
||||
if (line.startsWith('new file mode')) status = 'added'
|
||||
else if (line.startsWith('deleted file mode')) status = 'deleted'
|
||||
else if (line.startsWith('rename from ')) { status = 'renamed'; oldPath = line.slice('rename from '.length) }
|
||||
else if (line.startsWith('rename to ')) newPath = line.slice('rename to '.length)
|
||||
else if (line.includes('Binary files')) { isBinary = true }
|
||||
i++
|
||||
}
|
||||
|
||||
if (isBinary) {
|
||||
files.push({ oldPath, newPath, status, hunks: [], isBinary: true, isLarge: false, additions: 0, deletions: 0 })
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip --- and +++ lines
|
||||
if (lines[i]?.startsWith('---')) i++
|
||||
if (lines[i]?.startsWith('+++')) i++
|
||||
|
||||
// Parse hunks
|
||||
const hunks: DiffHunk[] = []
|
||||
let additions = 0
|
||||
let deletions = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
if (!line.startsWith('@@ ')) { i++; continue }
|
||||
|
||||
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/)
|
||||
if (!hunkMatch) { i++; continue }
|
||||
|
||||
const oldStart = parseInt(hunkMatch[1], 10)
|
||||
const oldCount = parseInt(hunkMatch[2] ?? '1', 10)
|
||||
const newStart = parseInt(hunkMatch[3], 10)
|
||||
const newCount = parseInt(hunkMatch[4] ?? '1', 10)
|
||||
const header = line
|
||||
|
||||
i++
|
||||
|
||||
const hunkLines: DiffLine[] = []
|
||||
let oldLineNum = oldStart
|
||||
let newLineNum = newStart
|
||||
|
||||
while (i < lines.length && !lines[i].startsWith('@@ ') && !lines[i].startsWith('diff --git ')) {
|
||||
const hunkLine = lines[i]
|
||||
if (hunkLine.startsWith('+')) {
|
||||
hunkLines.push({ type: 'added', content: hunkLine.slice(1), newLineNumber: newLineNum++ })
|
||||
additions++
|
||||
} else if (hunkLine.startsWith('-')) {
|
||||
hunkLines.push({ type: 'removed', content: hunkLine.slice(1), oldLineNumber: oldLineNum++ })
|
||||
deletions++
|
||||
} else if (hunkLine.startsWith('\\')) {
|
||||
hunkLines.push({ type: 'no-newline', content: hunkLine.slice(1) })
|
||||
} else {
|
||||
hunkLines.push({ type: 'context', content: hunkLine.slice(1), oldLineNumber: oldLineNum++, newLineNumber: newLineNum++ })
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
hunks.push({ header, oldStart, oldCount, newStart, newCount, lines: hunkLines })
|
||||
}
|
||||
|
||||
files.push({ oldPath, newPath, status, hunks, isBinary: false, isLarge: false, additions, deletions })
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
152
electron/git/executor.ts
Normal file
152
electron/git/executor.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { createLogger } from '../logger'
|
||||
import { spawn, ChildProcess } from 'child_process'
|
||||
import { getGitExecutable } from './detector'
|
||||
|
||||
const logger = createLogger('git:executor')
|
||||
|
||||
export interface GitResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
export class GitError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly exitCode: number,
|
||||
public readonly stderr: string,
|
||||
public readonly command: string[]
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'GitError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExecOptions {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
timeout?: number // ms
|
||||
stdin?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Git command safely using an argument array (never a shell string).
|
||||
* The gitExecutable and all arguments are passed as separate argv entries.
|
||||
*/
|
||||
export async function execGit(args: string[], options: ExecOptions = {}): Promise<GitResult> {
|
||||
const gitPath = getGitExecutable()
|
||||
if (!gitPath) {
|
||||
throw new GitError('Git executable not found', -1, '', args)
|
||||
}
|
||||
|
||||
// Log sanitized command (no credentials)
|
||||
const sanitizedArgs = sanitizeArgs(args)
|
||||
logger.debug('git', sanitizedArgs.join(' '), options.cwd ? `in ${options.cwd}` : '')
|
||||
|
||||
return new Promise<GitResult>((resolve, reject) => {
|
||||
let proc: ChildProcess | null = null
|
||||
let timedOut = false
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...process.env as Record<string, string>,
|
||||
GIT_TERMINAL_PROMPT: '0', // Prevent interactive prompts
|
||||
GIT_ASKPASS: 'echo', // Prevent credential pop-ups
|
||||
...(options.env ?? {}),
|
||||
}
|
||||
|
||||
try {
|
||||
proc = spawn(gitPath, args, {
|
||||
cwd: options.cwd,
|
||||
env,
|
||||
windowsHide: true,
|
||||
})
|
||||
} catch (err) {
|
||||
reject(new GitError(`Failed to spawn git: ${err}`, -1, '', args))
|
||||
return
|
||||
}
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
if (options.stdin && proc.stdin) {
|
||||
proc.stdin.write(options.stdin)
|
||||
proc.stdin.end()
|
||||
}
|
||||
|
||||
const timeout = options.timeout
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
if (timeout) {
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true
|
||||
proc?.kill()
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
// Handle AbortSignal
|
||||
const abortHandler = (): void => {
|
||||
proc?.kill()
|
||||
reject(new GitError('Git operation aborted', -2, '', args))
|
||||
}
|
||||
options.signal?.addEventListener('abort', abortHandler)
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
options.signal?.removeEventListener('abort', abortHandler)
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
reject(new GitError('Git operation aborted', -2, '', args))
|
||||
return
|
||||
}
|
||||
|
||||
if (timedOut) {
|
||||
reject(new GitError('Git operation timed out', -3, '', args))
|
||||
return
|
||||
}
|
||||
|
||||
const exitCode = code ?? -1
|
||||
logger.debug(`git exit ${exitCode}${exitCode !== 0 ? ` stderr: ${stderr.slice(0, 200)}` : ''}`)
|
||||
|
||||
resolve({ stdout, stderr, exitCode })
|
||||
})
|
||||
|
||||
proc.on('error', (err) => {
|
||||
if (timer) clearTimeout(timer)
|
||||
reject(new GitError(`Git process error: ${err.message}`, -1, '', args))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute git and throw if exit code is non-zero.
|
||||
*/
|
||||
export async function execGitOrThrow(args: string[], options: ExecOptions = {}): Promise<GitResult> {
|
||||
const result = await execGit(args, options)
|
||||
if (result.exitCode !== 0) {
|
||||
throw new GitError(
|
||||
`git ${args[0]} failed: ${result.stderr || result.stdout}`,
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
args
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove credential-bearing arguments from log output.
|
||||
* Redacts anything that looks like a URL with credentials (user:token@host).
|
||||
*/
|
||||
function sanitizeArgs(args: string[]): string[] {
|
||||
return args.map((arg) =>
|
||||
arg.replace(/\/\/[^@\s]+@/, '//***@')
|
||||
)
|
||||
}
|
||||
319
electron/git/history.ts
Normal file
319
electron/git/history.ts
Normal file
@ -0,0 +1,319 @@
|
||||
import { execGit, execGitOrThrow } from './executor'
|
||||
import type { Commit, CommitDetail, CommitFile, GraphData, GraphNode, GraphEdge } from '../../shared/types/git.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('git:history')
|
||||
|
||||
const LOG_FORMAT =
|
||||
'%H%n%h%n%s%n%b%n%aN%n%aE%n%aI%n%cN%n%cE%n%cI%n%P%n%D%n---COMMIT---'
|
||||
|
||||
export interface LogOptions {
|
||||
skip?: number
|
||||
limit?: number
|
||||
ref?: string
|
||||
author?: string
|
||||
search?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commit log.
|
||||
*/
|
||||
export async function getLog(repoPath: string, options: LogOptions = {}): Promise<Commit[]> {
|
||||
const args = ['log', `--format=${LOG_FORMAT}`, '--no-walk=unsorted']
|
||||
|
||||
if (options.limit) args.push(`-${options.limit}`)
|
||||
if (options.skip) args.push(`--skip=${options.skip}`)
|
||||
if (options.author) args.push(`--author=${options.author}`)
|
||||
if (options.search) args.push(`--grep=${options.search}`, '--regexp-ignore-case')
|
||||
if (options.ref) args.push(options.ref)
|
||||
else args.push('--all')
|
||||
if (options.path) args.push('--', options.path)
|
||||
|
||||
// Remove --no-walk for actual log
|
||||
const actualArgs = args.filter(a => a !== '--no-walk=unsorted')
|
||||
actualArgs.splice(1, 0, '--all')
|
||||
|
||||
const result = await execGit(
|
||||
['log', `--format=${LOG_FORMAT}`, '--all',
|
||||
...(options.limit ? [`-${options.limit}`] : []),
|
||||
...(options.skip ? [`--skip=${options.skip}`] : []),
|
||||
...(options.author ? [`--author=${options.author}`] : []),
|
||||
...(options.search ? [`--grep=${options.search}`, '--regexp-ignore-case'] : []),
|
||||
...(options.ref ? [options.ref] : []),
|
||||
...(options.path ? ['--', options.path] : []),
|
||||
],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
|
||||
if (result.exitCode !== 0) return []
|
||||
return parseLog(result.stdout)
|
||||
}
|
||||
|
||||
function parseLog(output: string): Commit[] {
|
||||
const commits: Commit[] = []
|
||||
const entries = output.split('---COMMIT---\n').filter(Boolean)
|
||||
|
||||
for (const entry of entries) {
|
||||
const lines = entry.split('\n')
|
||||
if (lines.length < 11) continue
|
||||
|
||||
const hash = lines[0].trim()
|
||||
const shortHash = lines[1].trim()
|
||||
const message = lines[2].trim()
|
||||
const body = lines[3].trim()
|
||||
const author = lines[4].trim()
|
||||
const authorEmail = lines[5].trim()
|
||||
const authorDate = lines[6].trim()
|
||||
const committer = lines[7].trim()
|
||||
const committerEmail = lines[8].trim()
|
||||
const committerDate = lines[9].trim()
|
||||
const parents = lines[10].trim().split(' ').filter(Boolean)
|
||||
const refStr = lines[11]?.trim() ?? ''
|
||||
const refs = refStr ? refStr.split(', ').map(r => r.trim()).filter(Boolean) : []
|
||||
|
||||
if (!hash) continue
|
||||
|
||||
commits.push({
|
||||
hash,
|
||||
shortHash,
|
||||
message,
|
||||
body,
|
||||
author,
|
||||
authorEmail,
|
||||
authorDate,
|
||||
committer,
|
||||
committerEmail,
|
||||
committerDate,
|
||||
parents,
|
||||
refs,
|
||||
isMerge: parents.length > 1,
|
||||
})
|
||||
}
|
||||
|
||||
return commits
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed commit info including file stats.
|
||||
*/
|
||||
export async function getCommitDetail(repoPath: string, hash: string): Promise<CommitDetail | null> {
|
||||
const [infoResult, filesResult] = await Promise.all([
|
||||
execGit(['log', '--format=%H%n%h%n%s%n%b%n%aN%n%aE%n%aI%n%cN%n%cE%n%cI%n%P%n%D', '-1', hash], { cwd: repoPath }),
|
||||
execGit(['diff-tree', '--no-commit-id', '-r', '--name-status', '-M', '-C', '--numstat', hash], { cwd: repoPath }),
|
||||
])
|
||||
|
||||
if (infoResult.exitCode !== 0) return null
|
||||
|
||||
const infoLines = infoResult.stdout.split('\n')
|
||||
const hash_ = infoLines[0].trim()
|
||||
const files = parseCommitFiles(filesResult.stdout)
|
||||
const stats = {
|
||||
additions: files.reduce((s, f) => s + f.additions, 0),
|
||||
deletions: files.reduce((s, f) => s + f.deletions, 0),
|
||||
files: files.length,
|
||||
}
|
||||
|
||||
return {
|
||||
hash: hash_,
|
||||
shortHash: infoLines[1].trim(),
|
||||
message: infoLines[2].trim(),
|
||||
body: infoLines[3].trim(),
|
||||
author: infoLines[4].trim(),
|
||||
authorEmail: infoLines[5].trim(),
|
||||
authorDate: infoLines[6].trim(),
|
||||
committer: infoLines[7].trim(),
|
||||
committerEmail: infoLines[8].trim(),
|
||||
committerDate: infoLines[9].trim(),
|
||||
parents: infoLines[10].trim().split(' ').filter(Boolean),
|
||||
refs: infoLines[11]?.trim().split(', ').filter(Boolean) ?? [],
|
||||
isMerge: infoLines[10].trim().split(' ').filter(Boolean).length > 1,
|
||||
files,
|
||||
stats,
|
||||
}
|
||||
}
|
||||
|
||||
function parseCommitFiles(output: string): CommitFile[] {
|
||||
const files: CommitFile[] = []
|
||||
const lines = output.trim().split('\n').filter(Boolean)
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.split('\t')
|
||||
if (parts.length < 2) continue
|
||||
|
||||
const stats = parts[0].split('\t')
|
||||
// numstat: additions\tdeletions\tpath or additions\tdeletions\t{old => new}
|
||||
if (parts.length === 3 && /^\d+/.test(parts[0])) {
|
||||
const additions = parseInt(parts[0], 10) || 0
|
||||
const deletions = parseInt(parts[1], 10) || 0
|
||||
const path = parts[2]
|
||||
files.push({ path, status: 'modified', additions, deletions })
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// BRANCH COLOR PALETTE — distinct, dark-theme friendly
|
||||
const BRANCH_COLORS = [
|
||||
'#4a9eff', // blue
|
||||
'#da5c2c', // ember (FlowGit accent)
|
||||
'#50c878', // green
|
||||
'#b47aff', // purple
|
||||
'#ff9f43', // orange
|
||||
'#ff6b9d', // pink
|
||||
'#26c6da', // cyan
|
||||
'#ffeb3b', // yellow
|
||||
]
|
||||
|
||||
/**
|
||||
* Build a graph data structure for the Blueprint-inspired visualization.
|
||||
* Uses a topological column-assignment algorithm.
|
||||
*/
|
||||
export async function getGraphData(repoPath: string, limit = 500): Promise<GraphData> {
|
||||
const result = await execGit(
|
||||
['log', '--all', '--format=%H%n%P%n%s%n%aN%n%aI%n%D', `-${limit}`],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
|
||||
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
||||
return { nodes: [], edges: [], columns: 0, totalRows: 0 }
|
||||
}
|
||||
|
||||
// Parse simplified log
|
||||
interface RawCommit {
|
||||
hash: string
|
||||
parents: string[]
|
||||
message: string
|
||||
author: string
|
||||
date: string
|
||||
refs: string[]
|
||||
}
|
||||
|
||||
const rawCommits: RawCommit[] = []
|
||||
const blocks = result.stdout.trim().split('\n\n').filter(Boolean)
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n')
|
||||
rawCommits.push({
|
||||
hash: lines[0]?.trim() ?? '',
|
||||
parents: (lines[1]?.trim() ?? '').split(' ').filter(Boolean),
|
||||
message: lines[2]?.trim() ?? '',
|
||||
author: lines[3]?.trim() ?? '',
|
||||
date: lines[4]?.trim() ?? '',
|
||||
refs: (lines[5]?.trim() ?? '').split(', ').filter(Boolean),
|
||||
})
|
||||
}
|
||||
|
||||
// Also parse with our main log parser for robustness
|
||||
const commits = await getLog(repoPath, { limit })
|
||||
if (!commits.length) return { nodes: [], edges: [], columns: 0, totalRows: 0 }
|
||||
|
||||
// Get HEAD
|
||||
const headResult = await execGit(['rev-parse', 'HEAD'], { cwd: repoPath })
|
||||
const headHash = headResult.exitCode === 0 ? headResult.stdout.trim() : null
|
||||
|
||||
// Assign columns using a simple lane algorithm
|
||||
const nodes: GraphNode[] = []
|
||||
const edges: GraphEdge[] = []
|
||||
const hashToRow = new Map<string, number>()
|
||||
const hashToColumn = new Map<string, number>()
|
||||
const lanes: (string | null)[] = [] // lane[col] = hash of commit currently occupying that lane
|
||||
|
||||
// Assign branch colors based on refs
|
||||
const branchColorMap = new Map<string, string>()
|
||||
let colorIndex = 0
|
||||
|
||||
function getBranchColor(branchName: string): string {
|
||||
if (!branchColorMap.has(branchName)) {
|
||||
branchColorMap.set(branchName, BRANCH_COLORS[colorIndex % BRANCH_COLORS.length])
|
||||
colorIndex++
|
||||
}
|
||||
return branchColorMap.get(branchName)!
|
||||
}
|
||||
|
||||
for (let row = 0; row < commits.length; row++) {
|
||||
const commit = commits[row]
|
||||
hashToRow.set(commit.hash, row)
|
||||
|
||||
// Find which lane this commit is in (if a child already reserved a lane for it)
|
||||
let col = hashToColumn.get(commit.hash) ?? -1
|
||||
if (col === -1) {
|
||||
// Find first empty lane
|
||||
col = lanes.findIndex((l) => l === null)
|
||||
if (col === -1) col = lanes.length
|
||||
lanes[col] = commit.hash
|
||||
hashToColumn.set(commit.hash, col)
|
||||
}
|
||||
|
||||
// Determine color
|
||||
const branchRef = commit.refs.find(r => !r.startsWith('HEAD') && !r.startsWith('tag:'))
|
||||
const color = branchRef ? getBranchColor(branchRef) : BRANCH_COLORS[col % BRANCH_COLORS.length]
|
||||
|
||||
nodes.push({
|
||||
hash: commit.hash,
|
||||
shortHash: commit.shortHash,
|
||||
message: commit.message,
|
||||
author: commit.author,
|
||||
authorDate: commit.authorDate,
|
||||
parents: commit.parents,
|
||||
children: [],
|
||||
refs: commit.refs,
|
||||
column: col,
|
||||
row,
|
||||
color,
|
||||
isMerge: commit.isMerge,
|
||||
isHead: commit.hash === headHash,
|
||||
})
|
||||
|
||||
// Mark lane as free if no parents in this lane
|
||||
if (commit.parents.length === 0) {
|
||||
lanes[col] = null
|
||||
}
|
||||
|
||||
// Assign lanes for parents
|
||||
for (let pi = 0; pi < commit.parents.length; pi++) {
|
||||
const parent = commit.parents[pi]
|
||||
if (!hashToColumn.has(parent)) {
|
||||
if (pi === 0) {
|
||||
// First parent stays in same lane
|
||||
hashToColumn.set(parent, col)
|
||||
lanes[col] = parent
|
||||
} else {
|
||||
// Subsequent parents (merge) get new lane
|
||||
const newCol = lanes.findIndex((l) => l === null)
|
||||
const mergeCol = newCol === -1 ? lanes.length : newCol
|
||||
hashToColumn.set(parent, mergeCol)
|
||||
lanes[mergeCol] = parent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build child relationships and edges
|
||||
const nodeMap = new Map(nodes.map(n => [n.hash, n]))
|
||||
|
||||
for (const node of nodes) {
|
||||
for (const parentHash of node.parents) {
|
||||
const parentNode = nodeMap.get(parentHash)
|
||||
if (parentNode) {
|
||||
parentNode.children.push(node.hash)
|
||||
}
|
||||
edges.push({
|
||||
from: node.hash,
|
||||
to: parentHash,
|
||||
color: node.color,
|
||||
type: node.isMerge && node.parents.indexOf(parentHash) > 0 ? 'merge' : 'parent',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const maxCol = nodes.reduce((m, n) => Math.max(m, n.column), 0)
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
columns: maxCol + 1,
|
||||
totalRows: nodes.length,
|
||||
}
|
||||
}
|
||||
85
electron/git/remote.ts
Normal file
85
electron/git/remote.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { execGit, execGitOrThrow } from './executor'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('git:remote')
|
||||
|
||||
export interface PushOptions {
|
||||
force?: boolean
|
||||
forceWithLease?: boolean
|
||||
setUpstream?: boolean
|
||||
}
|
||||
|
||||
/** Fetch from remote(s). */
|
||||
export async function fetchRemote(repoPath: string, remote?: string): Promise<void> {
|
||||
const args = ['fetch', '--prune']
|
||||
if (remote) args.push(remote)
|
||||
else args.push('--all')
|
||||
await execGitOrThrow(args, { cwd: repoPath, timeout: 60_000 })
|
||||
}
|
||||
|
||||
/** Pull from remote. */
|
||||
export async function pullRemote(
|
||||
repoPath: string,
|
||||
remote?: string,
|
||||
branch?: string
|
||||
): Promise<{ success: boolean; conflicts: boolean }> {
|
||||
const args = ['pull']
|
||||
if (remote) args.push(remote)
|
||||
if (branch) args.push(branch)
|
||||
|
||||
const result = await execGit(args, { cwd: repoPath, timeout: 60_000 })
|
||||
if (result.exitCode === 0) return { success: true, conflicts: false }
|
||||
if (result.stdout.includes('CONFLICT') || result.stderr.includes('conflict')) {
|
||||
return { success: false, conflicts: true }
|
||||
}
|
||||
throw new Error(classifyError(result.stderr || result.stdout))
|
||||
}
|
||||
|
||||
/** Push to remote. */
|
||||
export async function pushRemote(
|
||||
repoPath: string,
|
||||
remote = 'origin',
|
||||
branch?: string,
|
||||
options: PushOptions = {}
|
||||
): Promise<void> {
|
||||
const args = ['push']
|
||||
|
||||
if (options.forceWithLease) {
|
||||
args.push('--force-with-lease')
|
||||
} else if (options.force) {
|
||||
args.push('--force')
|
||||
}
|
||||
|
||||
if (options.setUpstream) {
|
||||
args.push('--set-upstream')
|
||||
}
|
||||
|
||||
args.push(remote)
|
||||
if (branch) args.push(branch)
|
||||
|
||||
const result = await execGit(args, { cwd: repoPath, timeout: 60_000 })
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(classifyError(result.stderr || result.stdout))
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify network errors into user-friendly messages. */
|
||||
function classifyError(output: string): string {
|
||||
const lower = output.toLowerCase()
|
||||
if (lower.includes('authentication failed') || lower.includes('could not read username')) {
|
||||
return 'Authentication failed. Check your credentials in settings.'
|
||||
}
|
||||
if (lower.includes('permission denied') || lower.includes('403')) {
|
||||
return 'Permission denied. You may not have access to this repository.'
|
||||
}
|
||||
if (lower.includes('could not resolve host') || lower.includes('network is unreachable')) {
|
||||
return 'Network error. Check your internet connection.'
|
||||
}
|
||||
if (lower.includes('rejected') && lower.includes('non-fast-forward')) {
|
||||
return 'Push rejected: remote contains work you do not have locally. Pull first.'
|
||||
}
|
||||
if (lower.includes('remote: repository not found') || lower.includes('not found')) {
|
||||
return 'Repository not found on remote. Check the remote URL.'
|
||||
}
|
||||
return output
|
||||
}
|
||||
64
electron/git/stash.ts
Normal file
64
electron/git/stash.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { execGit, execGitOrThrow } from './executor'
|
||||
import type { StashEntry } from '../../shared/types/git.types'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('git:stash')
|
||||
|
||||
export async function listStash(repoPath: string): Promise<StashEntry[]> {
|
||||
const result = await execGit(
|
||||
['stash', 'list', '--format=%gd%n%H%n%s%n%aI'],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
if (result.exitCode !== 0 || !result.stdout.trim()) return []
|
||||
|
||||
const entries: StashEntry[] = []
|
||||
const lines = result.stdout.trim().split('\n')
|
||||
|
||||
for (let i = 0; i < lines.length; i += 4) {
|
||||
const stashRef = lines[i]?.trim()
|
||||
const hash = lines[i + 1]?.trim()
|
||||
const message = lines[i + 2]?.trim() ?? ''
|
||||
const date = lines[i + 3]?.trim() ?? ''
|
||||
|
||||
if (!stashRef) continue
|
||||
|
||||
const indexMatch = stashRef.match(/stash@\{(\d+)\}/)
|
||||
const index = indexMatch ? parseInt(indexMatch[1], 10) : entries.length
|
||||
|
||||
const branchMatch = message.match(/^WIP on (.+?):/)
|
||||
const branch = branchMatch ? branchMatch[1] : 'unknown'
|
||||
|
||||
entries.push({ index, hash, message, branch, date })
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
export async function createStash(
|
||||
repoPath: string,
|
||||
message?: string,
|
||||
includeUntracked = false
|
||||
): Promise<void> {
|
||||
const args = ['stash', 'push']
|
||||
if (includeUntracked) args.push('--include-untracked')
|
||||
if (message) args.push('-m', message)
|
||||
await execGitOrThrow(args, { cwd: repoPath })
|
||||
}
|
||||
|
||||
export async function applyStash(repoPath: string, index: number): Promise<{ conflicts: boolean }> {
|
||||
const result = await execGit(['stash', 'apply', `stash@{${index}}`], { cwd: repoPath })
|
||||
if (result.exitCode === 0) return { conflicts: false }
|
||||
if (result.stdout.includes('CONFLICT')) return { conflicts: true }
|
||||
throw new Error(result.stderr || result.stdout)
|
||||
}
|
||||
|
||||
export async function popStash(repoPath: string, index: number): Promise<{ conflicts: boolean }> {
|
||||
const result = await execGit(['stash', 'pop', `stash@{${index}}`], { cwd: repoPath })
|
||||
if (result.exitCode === 0) return { conflicts: false }
|
||||
if (result.stdout.includes('CONFLICT')) return { conflicts: true }
|
||||
throw new Error(result.stderr || result.stdout)
|
||||
}
|
||||
|
||||
export async function dropStash(repoPath: string, index: number): Promise<void> {
|
||||
await execGitOrThrow(['stash', 'drop', `stash@{${index}}`], { cwd: repoPath })
|
||||
}
|
||||
19
electron/ipc/git.ipc.ts
Normal file
19
electron/ipc/git.ipc.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../shared/constants'
|
||||
import { detectGit, setGitPath } from '../git/detector'
|
||||
import { getSetting } from '../settings/settings-store'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('ipc:git')
|
||||
|
||||
export function registerGitIpc(): void {
|
||||
ipcMain.handle(IPC.GIT_DETECT, async () => {
|
||||
const customPath = getSetting('gitPath')
|
||||
return detectGit(customPath)
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.GIT_VERSION, async () => {
|
||||
const result = await detectGit(getSetting('gitPath'))
|
||||
return result.version
|
||||
})
|
||||
}
|
||||
33
electron/ipc/provider.ipc.ts
Normal file
33
electron/ipc/provider.ipc.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../shared/constants'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('ipc:provider')
|
||||
|
||||
// Provider IPC handlers — stub for Phase 5 (provider integrations)
|
||||
export function registerProviderIpc(): void {
|
||||
ipcMain.handle(IPC.PROVIDER_LIST, () => {
|
||||
return { success: true, data: [] }
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.PROVIDER_CONNECT, async (_, type: string, token: string, baseUrl?: string) => {
|
||||
// Phase 5: implement actual provider auth
|
||||
return { success: false, error: 'Provider connections coming in Phase 5' }
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.PROVIDER_DISCONNECT, async (_, id: string) => {
|
||||
return { success: true, data: null }
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.PROVIDER_REPOS, async (_, accountId: string) => {
|
||||
return { success: true, data: [] }
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.PROVIDER_PRS, async () => {
|
||||
return { success: true, data: [] }
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.PROVIDER_CREATE_PR, async () => {
|
||||
return { success: false, error: 'PR creation coming in Phase 5' }
|
||||
})
|
||||
}
|
||||
606
electron/ipc/repo.ipc.ts
Normal file
606
electron/ipc/repo.ipc.ts
Normal file
@ -0,0 +1,606 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../shared/constants'
|
||||
import { execGit, execGitOrThrow } from '../git/executor'
|
||||
import { getRepoStatus, stageFile, unstageFile, stageAll, unstageAll, discardFile, discardAll, applyPatch } from '../git/changes'
|
||||
import { getLog, getGraphData, getCommitDetail } from '../git/history'
|
||||
import { getFileDiff, getCommitDiff, getCommitsDiff, getBranchDiff } from '../git/diff'
|
||||
import { listBranches, createBranch, renameBranch, switchBranch, deleteBranch, mergeBranch, rebaseBranch, listTags, createTag, deleteTag, pushTag, listRemotes, addRemote, removeRemote } from '../git/branches'
|
||||
import { listStash, createStash, applyStash, popStash, dropStash } from '../git/stash'
|
||||
import { fetchRemote, pullRemote, pushRemote } from '../git/remote'
|
||||
import { listConflicts, markResolved, continueOperation, abortOperation } from '../git/conflict'
|
||||
import { blameFile } from '../git/blame'
|
||||
import { upsertRepository, getAllRepositories, getRepository, removeRepository } from '../persistence/database'
|
||||
import { statSync, existsSync } from 'fs'
|
||||
import { basename, resolve as resolvePath } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '../logger'
|
||||
import type { IpcResult } from '../../shared/types/ipc.types'
|
||||
|
||||
const logger = createLogger('ipc:repo')
|
||||
|
||||
function ok<T>(data: T): IpcResult<T> {
|
||||
return { success: true, data }
|
||||
}
|
||||
|
||||
function err(error: string, code?: string): IpcResult<never> {
|
||||
return { success: false, error, code }
|
||||
}
|
||||
|
||||
async function isValidGitRepo(path: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await execGit(['rev-parse', '--git-dir'], { cwd: path, timeout: 5000 })
|
||||
return result.exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function registerRepoIpc(): void {
|
||||
// ——— Repository management ———
|
||||
|
||||
ipcMain.handle(IPC.REPO_LIST, () => {
|
||||
return ok(getAllRepositories())
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REPO_OPEN, async (_, path: string) => {
|
||||
try {
|
||||
if (!existsSync(path)) return err(`Path does not exist: ${path}`)
|
||||
const isRepo = await isValidGitRepo(path)
|
||||
if (!isRepo) return err(`Not a Git repository: ${path}`)
|
||||
|
||||
const id = randomUUID()
|
||||
const statusResult = await getRepoStatus(path)
|
||||
|
||||
const repo = {
|
||||
id,
|
||||
path,
|
||||
name: basename(path),
|
||||
lastOpened: new Date().toISOString(),
|
||||
isPinned: false,
|
||||
currentBranch: statusResult.branch,
|
||||
isValid: true,
|
||||
}
|
||||
|
||||
upsertRepository(repo)
|
||||
return ok({ ...repo, remotes: [] })
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REPO_INIT, async (_, path: string) => {
|
||||
try {
|
||||
await execGitOrThrow(['init', path])
|
||||
const id = randomUUID()
|
||||
const repo = {
|
||||
id,
|
||||
path: resolvePath(path),
|
||||
name: basename(path),
|
||||
lastOpened: new Date().toISOString(),
|
||||
isPinned: false,
|
||||
currentBranch: 'main',
|
||||
isValid: true,
|
||||
}
|
||||
upsertRepository(repo)
|
||||
return ok({ ...repo, remotes: [] })
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REPO_CLONE, async (_, url: string, targetPath: string) => {
|
||||
try {
|
||||
await execGitOrThrow(['clone', url, targetPath], { timeout: 300_000 })
|
||||
const repoPath = resolvePath(targetPath)
|
||||
const id = randomUUID()
|
||||
const statusResult = await getRepoStatus(repoPath)
|
||||
const repo = {
|
||||
id,
|
||||
path: repoPath,
|
||||
name: basename(repoPath),
|
||||
lastOpened: new Date().toISOString(),
|
||||
isPinned: false,
|
||||
currentBranch: statusResult.branch,
|
||||
isValid: true,
|
||||
}
|
||||
upsertRepository(repo)
|
||||
return ok({ ...repo, remotes: [] })
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REPO_STATUS, async (_, repoPath: string) => {
|
||||
try {
|
||||
const status = await getRepoStatus(repoPath)
|
||||
return ok(status)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REPO_ADD, async (_, repoPath: string) => {
|
||||
try {
|
||||
if (!existsSync(repoPath)) return err('Path does not exist')
|
||||
const isRepo = await isValidGitRepo(repoPath)
|
||||
if (!isRepo) return err('Not a Git repository')
|
||||
|
||||
const existing = getAllRepositories().find(r => r.path === repoPath)
|
||||
if (existing) return ok(existing)
|
||||
|
||||
const id = randomUUID()
|
||||
const statusResult = await getRepoStatus(repoPath)
|
||||
const repo = {
|
||||
id,
|
||||
path: repoPath,
|
||||
name: basename(repoPath),
|
||||
lastOpened: new Date().toISOString(),
|
||||
isPinned: false,
|
||||
currentBranch: statusResult.branch,
|
||||
isValid: true,
|
||||
}
|
||||
upsertRepository(repo)
|
||||
return ok({ ...repo, remotes: [] })
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REPO_REMOVE, async (_, id: string) => {
|
||||
try {
|
||||
removeRepository(id)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REPO_REFRESH, async (_, repoPath: string) => {
|
||||
try {
|
||||
const status = await getRepoStatus(repoPath)
|
||||
return ok(status)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ——— Changes & Staging ———
|
||||
|
||||
ipcMain.handle(IPC.CHANGES_GET, async (_, repoPath: string) => {
|
||||
try {
|
||||
const status = await getRepoStatus(repoPath)
|
||||
return ok(status)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.CHANGES_STAGE, async (_, repoPath: string, filePath: string) => {
|
||||
try {
|
||||
await stageFile(repoPath, filePath)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.CHANGES_UNSTAGE, async (_, repoPath: string, filePath: string) => {
|
||||
try {
|
||||
await unstageFile(repoPath, filePath)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.CHANGES_STAGE_ALL, async (_, repoPath: string) => {
|
||||
try {
|
||||
await stageAll(repoPath)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.CHANGES_UNSTAGE_ALL, async (_, repoPath: string) => {
|
||||
try {
|
||||
await unstageAll(repoPath)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.CHANGES_DISCARD, async (_, repoPath: string, filePath: string) => {
|
||||
try {
|
||||
await discardFile(repoPath, filePath)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.CHANGES_DISCARD_ALL, async (_, repoPath: string) => {
|
||||
try {
|
||||
await discardAll(repoPath)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ——— Commits ———
|
||||
|
||||
ipcMain.handle(IPC.COMMIT_CREATE, async (_, repoPath: string, message: string, options?: { amend?: boolean }) => {
|
||||
try {
|
||||
if (!message.trim()) return err('Commit message cannot be empty')
|
||||
const args = ['commit', '-m', message]
|
||||
if (options?.amend) args.push('--amend')
|
||||
await execGitOrThrow(args, { cwd: repoPath })
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.COMMIT_AMEND, async (_, repoPath: string, message: string) => {
|
||||
try {
|
||||
await execGitOrThrow(['commit', '--amend', '-m', message], { cwd: repoPath })
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.COMMIT_CHERRY_PICK, async (_, repoPath: string, hash: string) => {
|
||||
try {
|
||||
const result = await execGit(['cherry-pick', hash], { cwd: repoPath })
|
||||
if (result.exitCode === 0) return ok({ conflicts: false })
|
||||
if (result.stdout.includes('CONFLICT')) return ok({ conflicts: true })
|
||||
return err(result.stderr)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.COMMIT_REVERT, async (_, repoPath: string, hash: string) => {
|
||||
try {
|
||||
await execGitOrThrow(['revert', hash, '--no-edit'], { cwd: repoPath })
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ——— Diff ———
|
||||
|
||||
ipcMain.handle(IPC.DIFF_FILE, async (_, repoPath: string, filePath: string, options?: { staged?: boolean; ref?: string }) => {
|
||||
try {
|
||||
const diff = await getFileDiff(repoPath, filePath, options)
|
||||
return ok(diff)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.DIFF_COMMIT, async (_, repoPath: string, hash: string) => {
|
||||
try {
|
||||
const diffs = await getCommitDiff(repoPath, hash)
|
||||
return ok(diffs)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.DIFF_COMMITS, async (_, repoPath: string, fromHash: string, toHash: string) => {
|
||||
try {
|
||||
const diffs = await getCommitsDiff(repoPath, fromHash, toHash)
|
||||
return ok(diffs)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.DIFF_BRANCH, async (_, repoPath: string, branchA: string, branchB: string) => {
|
||||
try {
|
||||
const diffs = await getBranchDiff(repoPath, branchA, branchB)
|
||||
return ok(diffs)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ——— History ———
|
||||
|
||||
ipcMain.handle(IPC.HISTORY_LOG, async (_, repoPath: string, options?: object) => {
|
||||
try {
|
||||
const commits = await getLog(repoPath, options ?? {})
|
||||
return ok(commits)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.HISTORY_GRAPH, async (_, repoPath: string, options?: { limit?: number }) => {
|
||||
try {
|
||||
const graph = await getGraphData(repoPath, options?.limit ?? 500)
|
||||
return ok(graph)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.HISTORY_COMMIT, async (_, repoPath: string, hash: string) => {
|
||||
try {
|
||||
const commit = await getCommitDetail(repoPath, hash)
|
||||
if (!commit) return err(`Commit not found: ${hash}`)
|
||||
return ok(commit)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.HISTORY_COMMIT_FILES, async (_, repoPath: string, hash: string) => {
|
||||
try {
|
||||
const commit = await getCommitDetail(repoPath, hash)
|
||||
return ok(commit?.files ?? [])
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ——— Branches ———
|
||||
|
||||
ipcMain.handle(IPC.BRANCH_LIST, async (_, repoPath: string) => {
|
||||
try {
|
||||
const branches = await listBranches(repoPath)
|
||||
return ok(branches)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.BRANCH_CREATE, async (_, repoPath: string, name: string, from?: string) => {
|
||||
try {
|
||||
await createBranch(repoPath, name, from)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.BRANCH_RENAME, async (_, repoPath: string, oldName: string, newName: string) => {
|
||||
try {
|
||||
await renameBranch(repoPath, oldName, newName)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.BRANCH_SWITCH, async (_, repoPath: string, name: string) => {
|
||||
try {
|
||||
await switchBranch(repoPath, name)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.BRANCH_DELETE, async (_, repoPath: string, name: string, force?: boolean) => {
|
||||
try {
|
||||
await deleteBranch(repoPath, name, force)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.BRANCH_MERGE, async (_, repoPath: string, source: string, options?: object) => {
|
||||
try {
|
||||
const result = await mergeBranch(repoPath, source, options ?? {})
|
||||
return ok(result)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.BRANCH_REBASE, async (_, repoPath: string, onto: string) => {
|
||||
try {
|
||||
const result = await rebaseBranch(repoPath, onto)
|
||||
return ok(result)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ——— Tags ———
|
||||
|
||||
ipcMain.handle(IPC.TAG_LIST, async (_, repoPath: string) => {
|
||||
try {
|
||||
const tags = await listTags(repoPath)
|
||||
return ok(tags)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.TAG_CREATE, async (_, repoPath: string, name: string, ref: string, message?: string) => {
|
||||
try {
|
||||
await createTag(repoPath, name, ref, message)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.TAG_DELETE, async (_, repoPath: string, name: string) => {
|
||||
try {
|
||||
await deleteTag(repoPath, name)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.TAG_PUSH, async (_, repoPath: string, remote: string, tagName: string) => {
|
||||
try {
|
||||
await pushTag(repoPath, remote, tagName)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ——— Stash ———
|
||||
|
||||
ipcMain.handle(IPC.STASH_LIST, async (_, repoPath: string) => {
|
||||
try {
|
||||
const stashes = await listStash(repoPath)
|
||||
return ok(stashes)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.STASH_CREATE, async (_, repoPath: string, message?: string, includeUntracked?: boolean) => {
|
||||
try {
|
||||
await createStash(repoPath, message, includeUntracked)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.STASH_APPLY, async (_, repoPath: string, index: number) => {
|
||||
try {
|
||||
const result = await applyStash(repoPath, index)
|
||||
return ok(result)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.STASH_POP, async (_, repoPath: string, index: number) => {
|
||||
try {
|
||||
const result = await popStash(repoPath, index)
|
||||
return ok(result)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.STASH_DROP, async (_, repoPath: string, index: number) => {
|
||||
try {
|
||||
await dropStash(repoPath, index)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ——— Remotes ———
|
||||
|
||||
ipcMain.handle(IPC.REMOTE_LIST, async (_, repoPath: string) => {
|
||||
try {
|
||||
const remotes = await listRemotes(repoPath)
|
||||
return ok(remotes)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REMOTE_ADD, async (_, repoPath: string, name: string, url: string) => {
|
||||
try {
|
||||
await addRemote(repoPath, name, url)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REMOTE_REMOVE, async (_, repoPath: string, name: string) => {
|
||||
try {
|
||||
await removeRemote(repoPath, name)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REMOTE_FETCH, async (_, repoPath: string, remote?: string) => {
|
||||
try {
|
||||
await fetchRemote(repoPath, remote)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REMOTE_PULL, async (_, repoPath: string, remote?: string, branch?: string) => {
|
||||
try {
|
||||
const result = await pullRemote(repoPath, remote, branch)
|
||||
return ok(result)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.REMOTE_PUSH, async (_, repoPath: string, remote?: string, branch?: string, force?: boolean) => {
|
||||
try {
|
||||
await pushRemote(repoPath, remote, branch, { forceWithLease: force })
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ——— Conflicts ———
|
||||
|
||||
ipcMain.handle(IPC.CONFLICT_LIST, async (_, repoPath: string) => {
|
||||
try {
|
||||
const conflicts = await listConflicts(repoPath)
|
||||
return ok(conflicts)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.CONFLICT_MARK_RESOLVED, async (_, repoPath: string, filePath: string) => {
|
||||
try {
|
||||
await markResolved(repoPath, filePath)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.CONFLICT_CONTINUE, async (_, repoPath: string) => {
|
||||
try {
|
||||
await continueOperation(repoPath)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.CONFLICT_ABORT, async (_, repoPath: string) => {
|
||||
try {
|
||||
await abortOperation(repoPath)
|
||||
return ok(null)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ——— Blame ———
|
||||
|
||||
ipcMain.handle(IPC.BLAME_FILE, async (_, repoPath: string, filePath: string, ref?: string) => {
|
||||
try {
|
||||
const blame = await blameFile(repoPath, filePath, ref)
|
||||
return ok(blame)
|
||||
} catch (e: any) {
|
||||
return err(e.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
33
electron/ipc/settings.ipc.ts
Normal file
33
electron/ipc/settings.ipc.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../shared/constants'
|
||||
import { getSettings, getSetting, setSetting } from '../settings/settings-store'
|
||||
import { setGitPath } from '../git/detector'
|
||||
import { createLogger } from '../logger'
|
||||
import type { AppSettings } from '../../shared/types/ipc.types'
|
||||
|
||||
const logger = createLogger('ipc:settings')
|
||||
|
||||
export function registerSettingsIpc(): void {
|
||||
ipcMain.handle(IPC.SETTINGS_GET, () => {
|
||||
return getSettings()
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC.SETTINGS_SET, (_, key: keyof AppSettings, value: unknown) => {
|
||||
try {
|
||||
// Type guard — validate the key exists
|
||||
const current = getSettings()
|
||||
if (!(key in current)) throw new Error(`Unknown setting: ${key}`)
|
||||
|
||||
setSetting(key, value as AppSettings[typeof key])
|
||||
|
||||
// Side effects for specific settings
|
||||
if (key === 'gitPath') {
|
||||
setGitPath(value as string | null)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message }
|
||||
}
|
||||
})
|
||||
}
|
||||
62
electron/ipc/terminal.ipc.ts
Normal file
62
electron/ipc/terminal.ipc.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron'
|
||||
import { IPC } from '../../shared/constants'
|
||||
import { spawn, ChildProcess } from 'child_process'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { createLogger } from '../logger'
|
||||
|
||||
const logger = createLogger('ipc:terminal')
|
||||
|
||||
const terminals = new Map<string, ChildProcess>()
|
||||
|
||||
export function registerTerminalIpc(): void {
|
||||
ipcMain.handle(IPC.TERMINAL_CREATE, async (event, repoPath: string) => {
|
||||
const id = randomUUID()
|
||||
|
||||
// Determine shell
|
||||
const shell = process.env.COMSPEC ?? 'cmd.exe'
|
||||
|
||||
const proc = spawn(shell, [], {
|
||||
cwd: repoPath,
|
||||
env: { ...process.env },
|
||||
windowsHide: false,
|
||||
})
|
||||
|
||||
terminals.set(id, proc)
|
||||
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
proc.stdout?.on('data', (data: Buffer) => {
|
||||
window?.webContents.send(IPC.TERMINAL_DATA + ':' + id, data.toString())
|
||||
})
|
||||
|
||||
proc.stderr?.on('data', (data: Buffer) => {
|
||||
window?.webContents.send(IPC.TERMINAL_DATA + ':' + id, data.toString())
|
||||
})
|
||||
|
||||
proc.on('exit', () => {
|
||||
terminals.delete(id)
|
||||
window?.webContents.send(IPC.TERMINAL_DATA + ':' + id, '\r\n[Process exited]\r\n')
|
||||
})
|
||||
|
||||
return id
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.TERMINAL_INPUT, (_, id: string, data: string) => {
|
||||
const proc = terminals.get(id)
|
||||
if (proc?.stdin) {
|
||||
proc.stdin.write(data)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.TERMINAL_RESIZE, (_event, _id: string, _cols: number, _rows: number) => {
|
||||
// PTY resize would require node-pty; basic terminal doesn't support this
|
||||
})
|
||||
|
||||
ipcMain.on(IPC.TERMINAL_DESTROY, (_, id: string) => {
|
||||
const proc = terminals.get(id)
|
||||
if (proc) {
|
||||
proc.kill()
|
||||
terminals.delete(id)
|
||||
}
|
||||
})
|
||||
}
|
||||
78
electron/logger.ts
Normal file
78
electron/logger.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { createWriteStream, mkdirSync } from 'fs'
|
||||
|
||||
// Log levels
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||
|
||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
}
|
||||
|
||||
// Only log debug in dev
|
||||
const MIN_LEVEL: LogLevel = process.env.NODE_ENV === 'development' ? 'debug' : 'info'
|
||||
|
||||
let logStream: ReturnType<typeof createWriteStream> | null = null
|
||||
|
||||
function getLogStream(): ReturnType<typeof createWriteStream> | null {
|
||||
if (logStream) return logStream
|
||||
try {
|
||||
const logDir = join(app.getPath('userData'), 'logs')
|
||||
mkdirSync(logDir, { recursive: true })
|
||||
const logFile = join(logDir, `flowgit-${new Date().toISOString().split('T')[0]}.log`)
|
||||
logStream = createWriteStream(logFile, { flags: 'a' })
|
||||
return logStream
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a log message to remove credentials.
|
||||
* Redacts: Bearer tokens, basic auth, URL credentials.
|
||||
*/
|
||||
function sanitize(msg: string): string {
|
||||
return msg
|
||||
.replace(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/g, 'Bearer ***')
|
||||
.replace(/Basic\s+[A-Za-z0-9+/=]+/g, 'Basic ***')
|
||||
.replace(/\/\/[^@\s]+@/g, '//***@')
|
||||
.replace(/token[=:]\s*[A-Za-z0-9\-._~+/]+/gi, 'token=***')
|
||||
.replace(/password[=:]\s*\S+/gi, 'password=***')
|
||||
}
|
||||
|
||||
function log(level: LogLevel, namespace: string, ...args: unknown[]): void {
|
||||
if (LOG_LEVEL_PRIORITY[level] < LOG_LEVEL_PRIORITY[MIN_LEVEL]) return
|
||||
|
||||
const ts = new Date().toISOString()
|
||||
const parts = args.map((a) => {
|
||||
if (typeof a === 'string') return sanitize(a)
|
||||
if (a instanceof Error) return `${a.message}\n${a.stack ?? ''}`
|
||||
return JSON.stringify(a)
|
||||
})
|
||||
|
||||
const line = `[${ts}] [${level.toUpperCase()}] [${namespace}] ${parts.join(' ')}`
|
||||
|
||||
// Console output (dev-friendly)
|
||||
if (level === 'error') console.error(line)
|
||||
else if (level === 'warn') console.warn(line)
|
||||
else console.log(line)
|
||||
|
||||
// File output
|
||||
try {
|
||||
getLogStream()?.write(line + '\n')
|
||||
} catch {
|
||||
// Don't crash if logging fails
|
||||
}
|
||||
}
|
||||
|
||||
export function createLogger(namespace: string) {
|
||||
return {
|
||||
debug: (...args: unknown[]) => log('debug', namespace, ...args),
|
||||
info: (...args: unknown[]) => log('info', namespace, ...args),
|
||||
warn: (...args: unknown[]) => log('warn', namespace, ...args),
|
||||
error: (...args: unknown[]) => log('error', namespace, ...args),
|
||||
}
|
||||
}
|
||||
151
electron/main.ts
Normal file
151
electron/main.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { app, BrowserWindow, shell, ipcMain, dialog } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { registerGitIpc } from './ipc/git.ipc'
|
||||
import { registerRepoIpc } from './ipc/repo.ipc'
|
||||
import { registerSettingsIpc } from './ipc/settings.ipc'
|
||||
import { registerProviderIpc } from './ipc/provider.ipc'
|
||||
import { registerTerminalIpc } from './ipc/terminal.ipc'
|
||||
import { initDatabase } from './persistence/database'
|
||||
import { getSettings } from './settings/settings-store'
|
||||
import { createLogger } from './logger'
|
||||
import { IPC } from '../shared/constants'
|
||||
|
||||
const logger = createLogger('main')
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const settings = getSettings()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
frame: false, // We use custom title bar
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#000000',
|
||||
symbolColor: '#eeeeee',
|
||||
height: 40
|
||||
},
|
||||
backgroundColor: '#000000',
|
||||
icon: join(__dirname, '../../resources/icon.ico'),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/preload.js'),
|
||||
sandbox: false, // Required for Node.js APIs in preload
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
webviewTag: false,
|
||||
spellcheck: false,
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow!.show()
|
||||
if (is.dev) {
|
||||
mainWindow!.webContents.openDevTools()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
// Open external URLs in system browser, not in app
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// Security: prevent navigation to external URLs
|
||||
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||
const appUrl = is.dev ? 'http://localhost:5173' : `file://`
|
||||
if (!url.startsWith(appUrl)) {
|
||||
event.preventDefault()
|
||||
shell.openExternal(url)
|
||||
}
|
||||
})
|
||||
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
return mainWindow
|
||||
}
|
||||
|
||||
// Register global IPC handlers
|
||||
function registerIpcHandlers(): void {
|
||||
// App info
|
||||
ipcMain.handle(IPC.APP_VERSION, () => app.getVersion())
|
||||
|
||||
// Window controls
|
||||
ipcMain.handle('window:minimize', () => {
|
||||
mainWindow?.minimize()
|
||||
})
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.unmaximize()
|
||||
} else {
|
||||
mainWindow?.maximize()
|
||||
}
|
||||
})
|
||||
ipcMain.handle('window:close', () => {
|
||||
mainWindow?.close()
|
||||
})
|
||||
ipcMain.handle('window:isMaximized', () => {
|
||||
return mainWindow?.isMaximized() ?? false
|
||||
})
|
||||
|
||||
// File/directory dialogs
|
||||
ipcMain.handle(IPC.DIALOG_OPEN_DIR, async (_, options?: { title?: string }) => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
title: options?.title ?? 'Select Directory',
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
// Shell
|
||||
ipcMain.handle(IPC.SHELL_OPEN, async (_, path: string) => {
|
||||
await shell.openPath(path)
|
||||
})
|
||||
|
||||
// Register domain-specific IPC
|
||||
registerGitIpc()
|
||||
registerRepoIpc()
|
||||
registerSettingsIpc()
|
||||
registerProviderIpc()
|
||||
registerTerminalIpc()
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
logger.info('FlowGit starting up')
|
||||
|
||||
// Initialize persistence
|
||||
await initDatabase()
|
||||
|
||||
// Register all IPC handlers
|
||||
registerIpcHandlers()
|
||||
|
||||
// Create the main window
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
logger.info('FlowGit shutting down')
|
||||
})
|
||||
101
electron/persistence/database.ts
Normal file
101
electron/persistence/database.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import Store from 'electron-store'
|
||||
import { createLogger } from '../logger'
|
||||
import type { Repository } from '../../shared/types/git.types'
|
||||
|
||||
const logger = createLogger('persistence:database')
|
||||
|
||||
interface DatabaseSchema {
|
||||
repositories: Repository[]
|
||||
uiState: Record<string, string>
|
||||
graphLayout: Record<string, { x: number; y: number }>
|
||||
}
|
||||
|
||||
let store: Store<DatabaseSchema> | null = null
|
||||
|
||||
export function getDb(): Store<DatabaseSchema> {
|
||||
if (!store) {
|
||||
store = new Store<DatabaseSchema>({
|
||||
name: 'flowgit-data',
|
||||
defaults: {
|
||||
repositories: [],
|
||||
uiState: {},
|
||||
graphLayout: {},
|
||||
},
|
||||
clearInvalidConfig: true,
|
||||
})
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
export async function initDatabase(): Promise<void> {
|
||||
logger.info('Initializing persistence store')
|
||||
getDb()
|
||||
logger.info('Persistence store initialized successfully')
|
||||
}
|
||||
|
||||
// ——— Repository CRUD ———
|
||||
|
||||
export function getAllRepositories(): Repository[] {
|
||||
const repos = getDb().get('repositories') ?? []
|
||||
return [...repos].sort((a, b) => new Date(b.lastOpened).getTime() - new Date(a.lastOpened).getTime())
|
||||
}
|
||||
|
||||
export function getRepository(id: string): Repository | null {
|
||||
const repos = getAllRepositories()
|
||||
return repos.find(r => r.id === id) ?? null
|
||||
}
|
||||
|
||||
export function upsertRepository(repo: Omit<Repository, 'remotes'>): void {
|
||||
const db = getDb()
|
||||
const repos = db.get('repositories') ?? []
|
||||
const existingIndex = repos.findIndex(r => r.id === repo.id || r.path === repo.path)
|
||||
|
||||
const fullRepo: Repository = {
|
||||
id: repo.id,
|
||||
path: repo.path,
|
||||
name: repo.name,
|
||||
lastOpened: repo.lastOpened,
|
||||
isPinned: repo.isPinned,
|
||||
currentBranch: repo.currentBranch,
|
||||
isValid: repo.isValid,
|
||||
remotes: [],
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
repos[existingIndex] = fullRepo
|
||||
} else {
|
||||
repos.unshift(fullRepo)
|
||||
}
|
||||
|
||||
db.set('repositories', repos)
|
||||
}
|
||||
|
||||
export function removeRepository(id: string): void {
|
||||
const db = getDb()
|
||||
const repos = db.get('repositories') ?? []
|
||||
db.set('repositories', repos.filter(r => r.id !== id))
|
||||
}
|
||||
|
||||
export function pinRepository(id: string, pinned: boolean): void {
|
||||
const db = getDb()
|
||||
const repos = db.get('repositories') ?? []
|
||||
const repo = repos.find(r => r.id === id)
|
||||
if (repo) {
|
||||
repo.isPinned = pinned
|
||||
db.set('repositories', repos)
|
||||
}
|
||||
}
|
||||
|
||||
// ——— UI State ———
|
||||
|
||||
export function getUiState(key: string): string | null {
|
||||
const state = getDb().get('uiState') ?? {}
|
||||
return state[key] ?? null
|
||||
}
|
||||
|
||||
export function setUiState(key: string, value: string): void {
|
||||
const db = getDb()
|
||||
const state = db.get('uiState') ?? {}
|
||||
state[key] = value
|
||||
db.set('uiState', state)
|
||||
}
|
||||
197
electron/preload.ts
Normal file
197
electron/preload.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { IPC } from '../shared/constants'
|
||||
import type { AppSettings, FlowGitApi } from '../shared/types/ipc.types'
|
||||
import type { CreatePRInput } from '../shared/types/provider.types'
|
||||
|
||||
// Typed IPC invoker helpers
|
||||
function invoke<T = any>(channel: string, ...args: unknown[]): Promise<T> {
|
||||
return ipcRenderer.invoke(channel, ...args)
|
||||
}
|
||||
|
||||
function on(channel: string, callback: (...args: unknown[]) => void): () => void {
|
||||
const sub = (_event: Electron.IpcRendererEvent, ...args: unknown[]): void => callback(...args)
|
||||
ipcRenderer.on(channel, sub)
|
||||
return () => ipcRenderer.removeListener(channel, sub)
|
||||
}
|
||||
|
||||
// The typed API surface exposed to the renderer via window.flowgit
|
||||
const api: FlowGitApi = {
|
||||
// App
|
||||
getVersion: () => invoke<string>(IPC.APP_VERSION),
|
||||
|
||||
// Window controls
|
||||
window: {
|
||||
minimize: () => invoke<void>('window:minimize'),
|
||||
maximize: () => invoke<void>('window:maximize'),
|
||||
close: () => invoke<void>('window:close'),
|
||||
isMaximized: () => invoke<boolean>('window:isMaximized'),
|
||||
},
|
||||
|
||||
// Dialogs
|
||||
openDirectory: (options?: { title?: string }) =>
|
||||
invoke<string | null>(IPC.DIALOG_OPEN_DIR, options),
|
||||
|
||||
// Shell
|
||||
openPath: (path: string) => invoke<void>(IPC.SHELL_OPEN, path),
|
||||
|
||||
// Git detection
|
||||
detectGit: () => invoke(IPC.GIT_DETECT),
|
||||
getGitVersion: () => invoke<string | null>(IPC.GIT_VERSION),
|
||||
|
||||
// Repository
|
||||
repo: {
|
||||
open: (path: string) => invoke(IPC.REPO_OPEN, path),
|
||||
init: (path: string) => invoke(IPC.REPO_INIT, path),
|
||||
clone: (url: string, targetPath: string, onProgress?: (p: unknown) => void) => {
|
||||
if (onProgress) {
|
||||
const unsub = on(IPC.REPO_CLONE + ':progress', onProgress)
|
||||
return invoke(IPC.REPO_CLONE, url, targetPath).finally(unsub)
|
||||
}
|
||||
return invoke(IPC.REPO_CLONE, url, targetPath)
|
||||
},
|
||||
getStatus: (repoPath: string) => invoke(IPC.REPO_STATUS, repoPath),
|
||||
list: () => invoke(IPC.REPO_LIST),
|
||||
add: (repoPath: string) => invoke(IPC.REPO_ADD, repoPath),
|
||||
remove: (repoId: string) => invoke(IPC.REPO_REMOVE, repoId),
|
||||
refresh: (repoPath: string) => invoke(IPC.REPO_REFRESH, repoPath),
|
||||
},
|
||||
|
||||
// Changes & staging
|
||||
changes: {
|
||||
get: (repoPath: string) => invoke(IPC.CHANGES_GET, repoPath),
|
||||
stage: (repoPath: string, filePath: string) => invoke(IPC.CHANGES_STAGE, repoPath, filePath),
|
||||
unstage: (repoPath: string, filePath: string) => invoke(IPC.CHANGES_UNSTAGE, repoPath, filePath),
|
||||
stageAll: (repoPath: string) => invoke(IPC.CHANGES_STAGE_ALL, repoPath),
|
||||
unstageAll: (repoPath: string) => invoke(IPC.CHANGES_UNSTAGE_ALL, repoPath),
|
||||
discard: (repoPath: string, filePath: string) => invoke(IPC.CHANGES_DISCARD, repoPath, filePath),
|
||||
discardAll: (repoPath: string) => invoke(IPC.CHANGES_DISCARD_ALL, repoPath),
|
||||
stageHunk: (repoPath: string, filePath: string, hunkIndex: number) =>
|
||||
invoke(IPC.CHANGES_STAGE_HUNK, repoPath, filePath, hunkIndex),
|
||||
unstageHunk: (repoPath: string, filePath: string, hunkIndex: number) =>
|
||||
invoke(IPC.CHANGES_UNSTAGE_HUNK, repoPath, filePath, hunkIndex),
|
||||
},
|
||||
|
||||
// Commits
|
||||
commit: {
|
||||
create: (repoPath: string, message: string, options?: { amend?: boolean }) =>
|
||||
invoke(IPC.COMMIT_CREATE, repoPath, message, options),
|
||||
amend: (repoPath: string, message: string) => invoke(IPC.COMMIT_AMEND, repoPath, message),
|
||||
cherryPick: (repoPath: string, hash: string) => invoke(IPC.COMMIT_CHERRY_PICK, repoPath, hash),
|
||||
revert: (repoPath: string, hash: string) => invoke(IPC.COMMIT_REVERT, repoPath, hash),
|
||||
},
|
||||
|
||||
// Diff
|
||||
diff: {
|
||||
file: (repoPath: string, filePath: string, options?: { staged?: boolean; ref?: string }) =>
|
||||
invoke(IPC.DIFF_FILE, repoPath, filePath, options),
|
||||
commit: (repoPath: string, hash: string) => invoke(IPC.DIFF_COMMIT, repoPath, hash),
|
||||
commits: (repoPath: string, fromHash: string, toHash: string) =>
|
||||
invoke(IPC.DIFF_COMMITS, repoPath, fromHash, toHash),
|
||||
branch: (repoPath: string, branchA: string, branchB: string) =>
|
||||
invoke(IPC.DIFF_BRANCH, repoPath, branchA, branchB),
|
||||
},
|
||||
|
||||
// History
|
||||
history: {
|
||||
log: (repoPath: string, options?: { skip?: number; limit?: number; ref?: string; author?: string; search?: string }) =>
|
||||
invoke(IPC.HISTORY_LOG, repoPath, options),
|
||||
graph: (repoPath: string, options?: { limit?: number }) =>
|
||||
invoke(IPC.HISTORY_GRAPH, repoPath, options),
|
||||
getCommit: (repoPath: string, hash: string) => invoke(IPC.HISTORY_COMMIT, repoPath, hash),
|
||||
getCommitFiles: (repoPath: string, hash: string) => invoke(IPC.HISTORY_COMMIT_FILES, repoPath, hash),
|
||||
},
|
||||
|
||||
// Branches
|
||||
branch: {
|
||||
list: (repoPath: string) => invoke(IPC.BRANCH_LIST, repoPath),
|
||||
create: (repoPath: string, name: string, from?: string) =>
|
||||
invoke(IPC.BRANCH_CREATE, repoPath, name, from),
|
||||
rename: (repoPath: string, oldName: string, newName: string) =>
|
||||
invoke(IPC.BRANCH_RENAME, repoPath, oldName, newName),
|
||||
switch: (repoPath: string, name: string) => invoke(IPC.BRANCH_SWITCH, repoPath, name),
|
||||
delete: (repoPath: string, name: string, force?: boolean) =>
|
||||
invoke(IPC.BRANCH_DELETE, repoPath, name, force),
|
||||
merge: (repoPath: string, source: string, options?: { strategy?: string }) =>
|
||||
invoke(IPC.BRANCH_MERGE, repoPath, source, options),
|
||||
rebase: (repoPath: string, onto: string) => invoke(IPC.BRANCH_REBASE, repoPath, onto),
|
||||
},
|
||||
|
||||
// Tags
|
||||
tag: {
|
||||
list: (repoPath: string) => invoke(IPC.TAG_LIST, repoPath),
|
||||
create: (repoPath: string, name: string, ref: string, message?: string) =>
|
||||
invoke(IPC.TAG_CREATE, repoPath, name, ref, message),
|
||||
delete: (repoPath: string, name: string) => invoke(IPC.TAG_DELETE, repoPath, name),
|
||||
push: (repoPath: string, remote: string, tagName: string) =>
|
||||
invoke(IPC.TAG_PUSH, repoPath, remote, tagName),
|
||||
},
|
||||
|
||||
// Stash
|
||||
stash: {
|
||||
list: (repoPath: string) => invoke(IPC.STASH_LIST, repoPath),
|
||||
create: (repoPath: string, message?: string, includeUntracked?: boolean) =>
|
||||
invoke(IPC.STASH_CREATE, repoPath, message, includeUntracked),
|
||||
apply: (repoPath: string, index: number) => invoke(IPC.STASH_APPLY, repoPath, index),
|
||||
pop: (repoPath: string, index: number) => invoke(IPC.STASH_POP, repoPath, index),
|
||||
drop: (repoPath: string, index: number) => invoke(IPC.STASH_DROP, repoPath, index),
|
||||
},
|
||||
|
||||
// Remotes
|
||||
remote: {
|
||||
list: (repoPath: string) => invoke(IPC.REMOTE_LIST, repoPath),
|
||||
add: (repoPath: string, name: string, url: string) => invoke(IPC.REMOTE_ADD, repoPath, name, url),
|
||||
remove: (repoPath: string, name: string) => invoke(IPC.REMOTE_REMOVE, repoPath, name),
|
||||
fetch: (repoPath: string, remote?: string) => invoke(IPC.REMOTE_FETCH, repoPath, remote),
|
||||
pull: (repoPath: string, remote?: string, branch?: string) =>
|
||||
invoke(IPC.REMOTE_PULL, repoPath, remote, branch),
|
||||
push: (repoPath: string, remote?: string, branch?: string, force?: boolean) =>
|
||||
invoke(IPC.REMOTE_PUSH, repoPath, remote, branch, force),
|
||||
},
|
||||
|
||||
// Conflicts
|
||||
conflict: {
|
||||
list: (repoPath: string) => invoke(IPC.CONFLICT_LIST, repoPath),
|
||||
markResolved: (repoPath: string, filePath: string) =>
|
||||
invoke(IPC.CONFLICT_MARK_RESOLVED, repoPath, filePath),
|
||||
continue: (repoPath: string) => invoke(IPC.CONFLICT_CONTINUE, repoPath),
|
||||
abort: (repoPath: string) => invoke(IPC.CONFLICT_ABORT, repoPath),
|
||||
},
|
||||
|
||||
// Providers
|
||||
provider: {
|
||||
list: () => invoke(IPC.PROVIDER_LIST),
|
||||
connect: (type: string, token: string, baseUrl?: string) =>
|
||||
invoke(IPC.PROVIDER_CONNECT, type, token, baseUrl),
|
||||
disconnect: (id: string) => invoke(IPC.PROVIDER_DISCONNECT, id),
|
||||
getRepos: (accountId: string) => invoke(IPC.PROVIDER_REPOS, accountId),
|
||||
getPRs: (repoPath: string, accountId: string) => invoke(IPC.PROVIDER_PRS, repoPath, accountId),
|
||||
createPR: (repoPath: string, accountId: string, input: CreatePRInput) =>
|
||||
invoke(IPC.PROVIDER_CREATE_PR, repoPath, accountId, input),
|
||||
},
|
||||
|
||||
// Settings
|
||||
settings: {
|
||||
get: () => invoke<AppSettings>(IPC.SETTINGS_GET),
|
||||
set: (key: keyof AppSettings, value: unknown) => invoke(IPC.SETTINGS_SET, key, value),
|
||||
},
|
||||
|
||||
// Terminal
|
||||
terminal: {
|
||||
create: (repoPath: string) => invoke<string>(IPC.TERMINAL_CREATE, repoPath),
|
||||
sendInput: (id: string, data: string) => ipcRenderer.send(IPC.TERMINAL_INPUT, id, data),
|
||||
resize: (id: string, cols: number, rows: number) =>
|
||||
ipcRenderer.send(IPC.TERMINAL_RESIZE, id, cols, rows),
|
||||
destroy: (id: string) => ipcRenderer.send(IPC.TERMINAL_DESTROY, id),
|
||||
onData: (id: string, callback: (data: string) => void) =>
|
||||
on(IPC.TERMINAL_DATA + ':' + id, (data) => callback(data as string)),
|
||||
},
|
||||
|
||||
// Blame
|
||||
blame: {
|
||||
file: (repoPath: string, filePath: string, ref?: string) =>
|
||||
invoke(IPC.BLAME_FILE, repoPath, filePath, ref),
|
||||
},
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld('flowgit', api)
|
||||
export { api }
|
||||
33
electron/settings/settings-store.ts
Normal file
33
electron/settings/settings-store.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import Store from 'electron-store'
|
||||
import type { AppSettings } from '../../shared/types/ipc.types'
|
||||
|
||||
const defaults: AppSettings = {
|
||||
gitPath: null,
|
||||
theme: 'dark',
|
||||
diffLayout: 'split',
|
||||
preferredEditor: null,
|
||||
preferredTerminal: null,
|
||||
autoFetch: false,
|
||||
autoFetchInterval: 15,
|
||||
pullStrategy: 'merge',
|
||||
showHiddenFiles: false,
|
||||
confirmDestructive: true,
|
||||
}
|
||||
|
||||
const store = new Store<AppSettings>({
|
||||
name: 'settings',
|
||||
defaults,
|
||||
clearInvalidConfig: true,
|
||||
})
|
||||
|
||||
export function getSettings(): AppSettings {
|
||||
return store.store
|
||||
}
|
||||
|
||||
export function getSetting<K extends keyof AppSettings>(key: K): AppSettings[K] {
|
||||
return store.get(key)
|
||||
}
|
||||
|
||||
export function setSetting<K extends keyof AppSettings>(key: K, value: AppSettings[K]): void {
|
||||
store.set(key, value)
|
||||
}
|
||||
10967
package-lock.json
generated
Normal file
10967
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
87
package.json
Normal file
87
package.json
Normal file
@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "flowgit",
|
||||
"version": "1.0.0",
|
||||
"description": "A premium Blueprint-inspired Git desktop application for Windows",
|
||||
"main": "out/main/main.js",
|
||||
"author": "FlowGit",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src electron shared --ext .ts,.tsx",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:dir": "npm run build && electron-builder --dir"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^3.0.0",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^33.4.1",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^3.0.0",
|
||||
"eslint": "^9.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^2.1.8",
|
||||
"zustand": "^5.0.2",
|
||||
"@tanstack/react-query": "^5.62.8",
|
||||
"react-router-dom": "^6.28.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-web-links": "^0.9.0",
|
||||
"dagre": "^0.8.5",
|
||||
"@types/dagre": "^0.7.3",
|
||||
"elkjs": "^0.9.3",
|
||||
"web-worker": "^1.3.0",
|
||||
"diff": "^7.0.0",
|
||||
"@types/diff": "^6.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"immer": "^10.1.1"
|
||||
},
|
||||
"build": {
|
||||
"npmRebuild": false,
|
||||
"appId": "com.flowgit.app",
|
||||
"productName": "FlowGit",
|
||||
"copyright": "Copyright © 2026 FlowGit",
|
||||
"win": {
|
||||
"signAndEditExecutable": false,
|
||||
"target": [
|
||||
{ "target": "nsis", "arch": ["x64"] },
|
||||
{ "target": "portable", "arch": ["x64"] }
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false
|
||||
},
|
||||
"files": [
|
||||
"out/**/*"
|
||||
],
|
||||
"directories": {
|
||||
"output": "release"
|
||||
}
|
||||
}
|
||||
}
|
||||
123
shared/constants.ts
Normal file
123
shared/constants.ts
Normal file
@ -0,0 +1,123 @@
|
||||
// Shared IPC channel constants — used by both main and renderer
|
||||
|
||||
export const IPC = {
|
||||
// Git detection
|
||||
GIT_DETECT: 'git:detect',
|
||||
GIT_VERSION: 'git:version',
|
||||
|
||||
// Repository management
|
||||
REPO_OPEN: 'repo:open',
|
||||
REPO_INIT: 'repo:init',
|
||||
REPO_CLONE: 'repo:clone',
|
||||
REPO_STATUS: 'repo:status',
|
||||
REPO_LIST: 'repo:list',
|
||||
REPO_ADD: 'repo:add',
|
||||
REPO_REMOVE: 'repo:remove',
|
||||
REPO_REFRESH: 'repo:refresh',
|
||||
|
||||
// Changes & staging
|
||||
CHANGES_GET: 'changes:get',
|
||||
CHANGES_STAGE: 'changes:stage',
|
||||
CHANGES_UNSTAGE: 'changes:unstage',
|
||||
CHANGES_STAGE_ALL: 'changes:stageAll',
|
||||
CHANGES_UNSTAGE_ALL: 'changes:unstageAll',
|
||||
CHANGES_DISCARD: 'changes:discard',
|
||||
CHANGES_DISCARD_ALL: 'changes:discardAll',
|
||||
CHANGES_STAGE_HUNK: 'changes:stageHunk',
|
||||
CHANGES_UNSTAGE_HUNK: 'changes:unstageHunk',
|
||||
|
||||
// Commits
|
||||
COMMIT_CREATE: 'commit:create',
|
||||
COMMIT_AMEND: 'commit:amend',
|
||||
|
||||
// Diff
|
||||
DIFF_FILE: 'diff:file',
|
||||
DIFF_COMMIT: 'diff:commit',
|
||||
DIFF_COMMITS: 'diff:commits',
|
||||
DIFF_BRANCH: 'diff:branch',
|
||||
|
||||
// History
|
||||
HISTORY_LOG: 'history:log',
|
||||
HISTORY_GRAPH: 'history:graph',
|
||||
HISTORY_COMMIT: 'history:commit',
|
||||
HISTORY_COMMIT_FILES: 'history:commitFiles',
|
||||
|
||||
// Branches
|
||||
BRANCH_LIST: 'branch:list',
|
||||
BRANCH_CREATE: 'branch:create',
|
||||
BRANCH_RENAME: 'branch:rename',
|
||||
BRANCH_SWITCH: 'branch:switch',
|
||||
BRANCH_DELETE: 'branch:delete',
|
||||
BRANCH_MERGE: 'branch:merge',
|
||||
BRANCH_REBASE: 'branch:rebase',
|
||||
|
||||
// Tags
|
||||
TAG_LIST: 'tag:list',
|
||||
TAG_CREATE: 'tag:create',
|
||||
TAG_DELETE: 'tag:delete',
|
||||
TAG_PUSH: 'tag:push',
|
||||
|
||||
// Stash
|
||||
STASH_LIST: 'stash:list',
|
||||
STASH_CREATE: 'stash:create',
|
||||
STASH_APPLY: 'stash:apply',
|
||||
STASH_POP: 'stash:pop',
|
||||
STASH_DROP: 'stash:drop',
|
||||
|
||||
// Remote
|
||||
REMOTE_LIST: 'remote:list',
|
||||
REMOTE_ADD: 'remote:add',
|
||||
REMOTE_REMOVE: 'remote:remove',
|
||||
REMOTE_FETCH: 'remote:fetch',
|
||||
REMOTE_PULL: 'remote:pull',
|
||||
REMOTE_PUSH: 'remote:push',
|
||||
|
||||
// Conflict resolution
|
||||
CONFLICT_LIST: 'conflict:list',
|
||||
CONFLICT_MARK_RESOLVED: 'conflict:markResolved',
|
||||
CONFLICT_CONTINUE: 'conflict:continue',
|
||||
CONFLICT_ABORT: 'conflict:abort',
|
||||
|
||||
// Cherry-pick / revert
|
||||
COMMIT_CHERRY_PICK: 'commit:cherryPick',
|
||||
COMMIT_REVERT: 'commit:revert',
|
||||
|
||||
// Providers
|
||||
PROVIDER_LIST: 'provider:list',
|
||||
PROVIDER_CONNECT: 'provider:connect',
|
||||
PROVIDER_DISCONNECT: 'provider:disconnect',
|
||||
PROVIDER_REPOS: 'provider:repos',
|
||||
PROVIDER_PRS: 'provider:prs',
|
||||
PROVIDER_CREATE_PR: 'provider:createPr',
|
||||
|
||||
// Settings
|
||||
SETTINGS_GET: 'settings:get',
|
||||
SETTINGS_SET: 'settings:set',
|
||||
|
||||
// Terminal
|
||||
TERMINAL_CREATE: 'terminal:create',
|
||||
TERMINAL_INPUT: 'terminal:input',
|
||||
TERMINAL_RESIZE: 'terminal:resize',
|
||||
TERMINAL_DESTROY: 'terminal:destroy',
|
||||
TERMINAL_DATA: 'terminal:data',
|
||||
|
||||
// Blame
|
||||
BLAME_FILE: 'blame:file',
|
||||
|
||||
// Dialog
|
||||
DIALOG_OPEN_DIR: 'dialog:openDir',
|
||||
DIALOG_SAVE_FILE: 'dialog:saveFile',
|
||||
|
||||
// Shell
|
||||
SHELL_OPEN: 'shell:open',
|
||||
|
||||
// App
|
||||
APP_VERSION: 'app:version',
|
||||
} as const
|
||||
|
||||
export type IpcChannel = (typeof IPC)[keyof typeof IPC]
|
||||
|
||||
export const APP_NAME = 'FlowGit'
|
||||
export const APP_DATA_DIR = 'FlowGit'
|
||||
export const DB_FILENAME = 'flowgit.db'
|
||||
export const SETTINGS_FILENAME = 'settings'
|
||||
199
shared/types/git.types.ts
Normal file
199
shared/types/git.types.ts
Normal file
@ -0,0 +1,199 @@
|
||||
// Shared Git types — used by both main process and renderer
|
||||
|
||||
export interface GitDetectionResult {
|
||||
found: boolean
|
||||
path: string | null
|
||||
version: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type FileStatus =
|
||||
| 'added'
|
||||
| 'modified'
|
||||
| 'deleted'
|
||||
| 'renamed'
|
||||
| 'copied'
|
||||
| 'untracked'
|
||||
| 'conflicted'
|
||||
| 'ignored'
|
||||
|
||||
export interface ChangedFile {
|
||||
path: string
|
||||
originalPath?: string // for renames
|
||||
status: FileStatus
|
||||
staged: boolean
|
||||
unstaged: boolean
|
||||
// x = index status, y = working tree status (raw git codes)
|
||||
indexStatus: string
|
||||
workingStatus: string
|
||||
}
|
||||
|
||||
export interface RepoStatus {
|
||||
branch: string | null
|
||||
detachedHead: boolean
|
||||
headCommit: string | null
|
||||
ahead: number
|
||||
behind: number
|
||||
staged: ChangedFile[]
|
||||
unstaged: ChangedFile[]
|
||||
untracked: ChangedFile[]
|
||||
conflicted: ChangedFile[]
|
||||
mergeInProgress: boolean
|
||||
rebaseInProgress: boolean
|
||||
cherryPickInProgress: boolean
|
||||
revertInProgress: boolean
|
||||
}
|
||||
|
||||
export interface Commit {
|
||||
hash: string
|
||||
shortHash: string
|
||||
message: string
|
||||
body: string
|
||||
author: string
|
||||
authorEmail: string
|
||||
authorDate: string
|
||||
committer: string
|
||||
committerEmail: string
|
||||
committerDate: string
|
||||
parents: string[]
|
||||
refs: string[] // branch/tag refs pointing to this commit
|
||||
isMerge: boolean
|
||||
}
|
||||
|
||||
export interface Branch {
|
||||
name: string
|
||||
fullName: string
|
||||
isRemote: boolean
|
||||
isCurrent: boolean
|
||||
upstream?: string
|
||||
ahead: number
|
||||
behind: number
|
||||
headCommit: string
|
||||
headMessage: string
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
name: string
|
||||
hash: string
|
||||
tagHash?: string
|
||||
message?: string
|
||||
isAnnotated: boolean
|
||||
date: string
|
||||
taggerName?: string
|
||||
taggerEmail?: string
|
||||
}
|
||||
|
||||
export interface Remote {
|
||||
name: string
|
||||
fetchUrl: string
|
||||
pushUrl: string
|
||||
}
|
||||
|
||||
export interface StashEntry {
|
||||
index: number
|
||||
hash: string
|
||||
message: string
|
||||
branch: string
|
||||
date: string
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
header: string
|
||||
oldStart: number
|
||||
oldCount: number
|
||||
newStart: number
|
||||
newCount: number
|
||||
lines: DiffLine[]
|
||||
}
|
||||
|
||||
export interface DiffLine {
|
||||
type: 'context' | 'added' | 'removed' | 'no-newline'
|
||||
content: string
|
||||
oldLineNumber?: number
|
||||
newLineNumber?: number
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
oldPath: string
|
||||
newPath: string
|
||||
status: FileStatus
|
||||
hunks: DiffHunk[]
|
||||
isBinary: boolean
|
||||
isLarge: boolean
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
export interface CommitDetail extends Commit {
|
||||
files: CommitFile[]
|
||||
stats: { additions: number; deletions: number; files: number }
|
||||
}
|
||||
|
||||
export interface CommitFile {
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: FileStatus
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
// Graph node for Blueprint-inspired visualization
|
||||
export interface GraphNode {
|
||||
hash: string
|
||||
shortHash: string
|
||||
message: string
|
||||
author: string
|
||||
authorDate: string
|
||||
parents: string[]
|
||||
children: string[]
|
||||
refs: string[]
|
||||
column: number // layout column
|
||||
row: number // layout row
|
||||
color: string // branch color
|
||||
isMerge: boolean
|
||||
isHead: boolean
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
from: string
|
||||
to: string
|
||||
color: string
|
||||
type: 'parent' | 'merge'
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
columns: number
|
||||
totalRows: number
|
||||
}
|
||||
|
||||
export interface ConflictFile {
|
||||
path: string
|
||||
ours: string
|
||||
theirs: string
|
||||
base?: string
|
||||
conflictCount: number
|
||||
}
|
||||
|
||||
export interface BlameEntry {
|
||||
hash: string
|
||||
shortHash: string
|
||||
author: string
|
||||
authorEmail: string
|
||||
date: string
|
||||
message: string
|
||||
lineNumber: number
|
||||
lineContent: string
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
id: string
|
||||
path: string
|
||||
name: string
|
||||
lastOpened: string
|
||||
isPinned: boolean
|
||||
remotes: Remote[]
|
||||
currentBranch: string | null
|
||||
isValid: boolean
|
||||
}
|
||||
170
shared/types/ipc.types.ts
Normal file
170
shared/types/ipc.types.ts
Normal file
@ -0,0 +1,170 @@
|
||||
// IPC contract types — typed request/response pairs for every channel
|
||||
|
||||
import type {
|
||||
GitDetectionResult,
|
||||
RepoStatus,
|
||||
ChangedFile,
|
||||
Commit,
|
||||
CommitDetail,
|
||||
CommitFile,
|
||||
Branch,
|
||||
Tag,
|
||||
Remote,
|
||||
StashEntry,
|
||||
FileDiff,
|
||||
GraphData,
|
||||
ConflictFile,
|
||||
BlameEntry,
|
||||
Repository,
|
||||
} from './git.types'
|
||||
import type { ProviderAccount, ProviderRepo, PullRequest, CreatePRInput } from './provider.types'
|
||||
|
||||
// Generic IPC result wrapper
|
||||
export interface IpcResult<T> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
// App settings shape
|
||||
export interface AppSettings {
|
||||
gitPath: string | null // null = auto-detect
|
||||
theme: 'dark' // only dark for now
|
||||
diffLayout: 'split' | 'unified'
|
||||
preferredEditor: string | null
|
||||
preferredTerminal: string | null
|
||||
autoFetch: boolean
|
||||
autoFetchInterval: number // minutes
|
||||
pullStrategy: 'merge' | 'rebase' | 'ff-only'
|
||||
showHiddenFiles: boolean
|
||||
confirmDestructive: boolean
|
||||
}
|
||||
|
||||
// Clone progress
|
||||
export interface CloneProgress {
|
||||
phase: string
|
||||
percent: number
|
||||
receivedObjects: number
|
||||
totalObjects: number
|
||||
}
|
||||
|
||||
// Operation progress (fetch/pull/push)
|
||||
export interface OperationProgress {
|
||||
operation: 'fetch' | 'pull' | 'push'
|
||||
phase: string
|
||||
percent: number
|
||||
message: string
|
||||
}
|
||||
|
||||
// The complete, strongly typed API surface exposed to the renderer via window.flowgit
|
||||
export interface FlowGitApi {
|
||||
getVersion: () => Promise<string>
|
||||
window: {
|
||||
minimize: () => Promise<void>
|
||||
maximize: () => Promise<void>
|
||||
close: () => Promise<void>
|
||||
isMaximized: () => Promise<boolean>
|
||||
}
|
||||
openDirectory: (options?: { title?: string }) => Promise<string | null>
|
||||
openPath: (path: string) => Promise<void>
|
||||
detectGit: () => Promise<GitDetectionResult>
|
||||
getGitVersion: () => Promise<string | null>
|
||||
repo: {
|
||||
open: (path: string) => Promise<IpcResult<Repository>>
|
||||
init: (path: string) => Promise<IpcResult<Repository>>
|
||||
clone: (url: string, targetPath: string, onProgress?: (p: unknown) => void) => Promise<IpcResult<Repository>>
|
||||
getStatus: (repoPath: string) => Promise<IpcResult<RepoStatus>>
|
||||
list: () => Promise<IpcResult<Repository[]>>
|
||||
add: (repoPath: string) => Promise<IpcResult<Repository>>
|
||||
remove: (repoId: string) => Promise<IpcResult<null>>
|
||||
refresh: (repoPath: string) => Promise<IpcResult<RepoStatus>>
|
||||
}
|
||||
changes: {
|
||||
get: (repoPath: string) => Promise<IpcResult<RepoStatus>>
|
||||
stage: (repoPath: string, filePath: string) => Promise<IpcResult<null>>
|
||||
unstage: (repoPath: string, filePath: string) => Promise<IpcResult<null>>
|
||||
stageAll: (repoPath: string) => Promise<IpcResult<null>>
|
||||
unstageAll: (repoPath: string) => Promise<IpcResult<null>>
|
||||
discard: (repoPath: string, filePath: string) => Promise<IpcResult<null>>
|
||||
discardAll: (repoPath: string) => Promise<IpcResult<null>>
|
||||
stageHunk: (repoPath: string, filePath: string, hunkIndex: number) => Promise<IpcResult<null>>
|
||||
unstageHunk: (repoPath: string, filePath: string, hunkIndex: number) => Promise<IpcResult<null>>
|
||||
}
|
||||
commit: {
|
||||
create: (repoPath: string, message: string, options?: { amend?: boolean }) => Promise<IpcResult<null>>
|
||||
amend: (repoPath: string, message: string) => Promise<IpcResult<null>>
|
||||
cherryPick: (repoPath: string, hash: string) => Promise<IpcResult<{ conflicts: boolean }>>
|
||||
revert: (repoPath: string, hash: string) => Promise<IpcResult<null>>
|
||||
}
|
||||
diff: {
|
||||
file: (repoPath: string, filePath: string, options?: { staged?: boolean; ref?: string }) => Promise<IpcResult<FileDiff>>
|
||||
commit: (repoPath: string, hash: string) => Promise<IpcResult<FileDiff[]>>
|
||||
commits: (repoPath: string, fromHash: string, toHash: string) => Promise<IpcResult<FileDiff[]>>
|
||||
branch: (repoPath: string, branchA: string, branchB: string) => Promise<IpcResult<FileDiff[]>>
|
||||
}
|
||||
history: {
|
||||
log: (repoPath: string, options?: { skip?: number; limit?: number; ref?: string; author?: string; search?: string }) => Promise<IpcResult<Commit[]>>
|
||||
graph: (repoPath: string, options?: { limit?: number }) => Promise<IpcResult<GraphData>>
|
||||
getCommit: (repoPath: string, hash: string) => Promise<IpcResult<CommitDetail>>
|
||||
getCommitFiles: (repoPath: string, hash: string) => Promise<IpcResult<CommitFile[]>>
|
||||
}
|
||||
branch: {
|
||||
list: (repoPath: string) => Promise<IpcResult<Branch[]>>
|
||||
create: (repoPath: string, name: string, from?: string) => Promise<IpcResult<null>>
|
||||
rename: (repoPath: string, oldName: string, newName: string) => Promise<IpcResult<null>>
|
||||
switch: (repoPath: string, name: string) => Promise<IpcResult<null>>
|
||||
delete: (repoPath: string, name: string, force?: boolean) => Promise<IpcResult<null>>
|
||||
merge: (repoPath: string, source: string, options?: { strategy?: string }) => Promise<IpcResult<{ success: boolean; conflicts: boolean }>>
|
||||
rebase: (repoPath: string, onto: string) => Promise<IpcResult<{ success: boolean; conflicts: boolean }>>
|
||||
}
|
||||
tag: {
|
||||
list: (repoPath: string) => Promise<IpcResult<Tag[]>>
|
||||
create: (repoPath: string, name: string, ref: string, message?: string) => Promise<IpcResult<null>>
|
||||
delete: (repoPath: string, name: string) => Promise<IpcResult<null>>
|
||||
push: (repoPath: string, remote: string, tagName: string) => Promise<IpcResult<null>>
|
||||
}
|
||||
stash: {
|
||||
list: (repoPath: string) => Promise<IpcResult<StashEntry[]>>
|
||||
create: (repoPath: string, message?: string, includeUntracked?: boolean) => Promise<IpcResult<null>>
|
||||
apply: (repoPath: string, index: number) => Promise<IpcResult<{ conflicts: boolean }>>
|
||||
pop: (repoPath: string, index: number) => Promise<IpcResult<{ conflicts: boolean }>>
|
||||
drop: (repoPath: string, index: number) => Promise<IpcResult<null>>
|
||||
}
|
||||
remote: {
|
||||
list: (repoPath: string) => Promise<IpcResult<Remote[]>>
|
||||
add: (repoPath: string, name: string, url: string) => Promise<IpcResult<null>>
|
||||
remove: (repoPath: string, name: string) => Promise<IpcResult<null>>
|
||||
fetch: (repoPath: string, remote?: string) => Promise<IpcResult<null>>
|
||||
pull: (repoPath: string, remote?: string, branch?: string) => Promise<IpcResult<{ success: boolean; conflicts: boolean }>>
|
||||
push: (repoPath: string, remote?: string, branch?: string, force?: boolean) => Promise<IpcResult<null>>
|
||||
}
|
||||
conflict: {
|
||||
list: (repoPath: string) => Promise<IpcResult<ConflictFile[]>>
|
||||
markResolved: (repoPath: string, filePath: string) => Promise<IpcResult<null>>
|
||||
continue: (repoPath: string) => Promise<IpcResult<null>>
|
||||
abort: (repoPath: string) => Promise<IpcResult<null>>
|
||||
}
|
||||
provider: {
|
||||
list: () => Promise<IpcResult<ProviderAccount[]>>
|
||||
connect: (type: string, token: string, baseUrl?: string) => Promise<IpcResult<never>>
|
||||
disconnect: (id: string) => Promise<IpcResult<null>>
|
||||
getRepos: (accountId: string) => Promise<IpcResult<ProviderRepo[]>>
|
||||
getPRs: (repoPath: string, accountId: string) => Promise<IpcResult<PullRequest[]>>
|
||||
createPR: (repoPath: string, accountId: string, input: CreatePRInput) => Promise<IpcResult<never>>
|
||||
}
|
||||
settings: {
|
||||
get: () => Promise<AppSettings>
|
||||
set: (key: keyof AppSettings, value: unknown) => Promise<{ success: boolean; error?: string }>
|
||||
}
|
||||
terminal: {
|
||||
create: (repoPath: string) => Promise<string>
|
||||
sendInput: (id: string, data: string) => void
|
||||
resize: (id: string, cols: number, rows: number) => void
|
||||
destroy: (id: string) => void
|
||||
onData: (id: string, callback: (data: string) => void) => () => void
|
||||
}
|
||||
blame: {
|
||||
file: (repoPath: string, filePath: string, ref?: string) => Promise<IpcResult<BlameEntry[]>>
|
||||
}
|
||||
}
|
||||
56
shared/types/provider.types.ts
Normal file
56
shared/types/provider.types.ts
Normal file
@ -0,0 +1,56 @@
|
||||
// Provider types — GitHub, GitLab, Gitea
|
||||
|
||||
export type ProviderType = 'github' | 'gitlab' | 'gitea'
|
||||
|
||||
export interface ProviderAccount {
|
||||
id: string
|
||||
type: ProviderType
|
||||
name: string // Display name / username
|
||||
baseUrl: string // https://github.com, https://gitlab.com, or self-hosted
|
||||
avatarUrl?: string
|
||||
connectedAt: string
|
||||
}
|
||||
|
||||
export interface ProviderRepo {
|
||||
id: string
|
||||
name: string
|
||||
fullName: string
|
||||
description?: string
|
||||
defaultBranch: string
|
||||
isPrivate: boolean
|
||||
cloneUrl: string
|
||||
sshUrl: string
|
||||
webUrl: string
|
||||
stars: number
|
||||
forks: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type PRState = 'open' | 'closed' | 'merged' | 'draft'
|
||||
|
||||
export interface PullRequest {
|
||||
id: number
|
||||
number: number
|
||||
title: string
|
||||
description: string
|
||||
state: PRState
|
||||
author: string
|
||||
authorAvatar?: string
|
||||
sourceBranch: string
|
||||
targetBranch: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
webUrl: string
|
||||
reviewCount: number
|
||||
commentCount: number
|
||||
isDraft: boolean
|
||||
isMergeable?: boolean
|
||||
}
|
||||
|
||||
export interface CreatePRInput {
|
||||
title: string
|
||||
description: string
|
||||
sourceBranch: string
|
||||
targetBranch: string
|
||||
isDraft: boolean
|
||||
}
|
||||
78
src/App.tsx
Normal file
78
src/App.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useRepoStore } from './store/repo.store'
|
||||
import { useUiStore } from './store/ui.store'
|
||||
import WelcomeView from './views/Welcome/WelcomeView'
|
||||
import RepositoryWorkspace from './views/Repository/RepositoryWorkspace'
|
||||
import SettingsView from './views/Settings/SettingsView'
|
||||
import ToastContainer from './components/ToastContainer'
|
||||
import ConfirmModal from './components/ConfirmModal'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 5000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const { activeRepoPath, setGitInfo, setRepositories } = useRepoStore()
|
||||
const { toasts, dismissToast, confirmModal, closeConfirmModal } = useUiStore()
|
||||
|
||||
// Detect Git and load repository list on startup
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
try {
|
||||
const detection = await window.flowgit.detectGit()
|
||||
setGitInfo(detection.found, detection.path, detection.version)
|
||||
} catch (e) {
|
||||
setGitInfo(false, null, null)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.flowgit.repo.list()
|
||||
if (result.success && result.data) {
|
||||
setRepositories(result.data)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HashRouter>
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: '#000000', overflow: 'hidden' }}>
|
||||
<Routes>
|
||||
<Route path="/" element={activeRepoPath ? <Navigate to="/repo" replace /> : <WelcomeView />} />
|
||||
<Route path="/welcome" element={<WelcomeView />} />
|
||||
<Route path="/repo" element={activeRepoPath ? <RepositoryWorkspace /> : <Navigate to="/" replace />} />
|
||||
<Route path="/settings" element={<SettingsView />} />
|
||||
</Routes>
|
||||
|
||||
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
||||
|
||||
{confirmModal.open && (
|
||||
<ConfirmModal
|
||||
title={confirmModal.title}
|
||||
message={confirmModal.message}
|
||||
confirmText={confirmModal.confirmText}
|
||||
isDangerous={confirmModal.isDangerous}
|
||||
onConfirm={() => {
|
||||
confirmModal.onConfirm?.()
|
||||
closeConfirmModal()
|
||||
}}
|
||||
onCancel={closeConfirmModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HashRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
146
src/components/Button.tsx
Normal file
146
src/components/Button.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'ghost' | 'danger' | 'secondary'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showArrow?: boolean
|
||||
loading?: boolean
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
showArrow = false,
|
||||
loading = false,
|
||||
icon,
|
||||
children,
|
||||
disabled,
|
||||
style,
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const isDisabled = disabled || loading
|
||||
|
||||
// Sizing definitions
|
||||
const sizeStyles: Record<string, React.CSSProperties> = {
|
||||
sm: {
|
||||
padding: '4px 10px',
|
||||
fontSize: '12px',
|
||||
height: '28px',
|
||||
gap: '6px',
|
||||
},
|
||||
md: {
|
||||
padding: '8px 16px',
|
||||
fontSize: '13px',
|
||||
height: '36px',
|
||||
gap: '8px',
|
||||
},
|
||||
lg: {
|
||||
padding: '10px 20px',
|
||||
fontSize: '14px',
|
||||
height: '42px',
|
||||
gap: '10px',
|
||||
},
|
||||
}
|
||||
|
||||
// Variant definitions
|
||||
const variantStyles: Record<string, React.CSSProperties> = {
|
||||
primary: {
|
||||
background: '#da5c2c',
|
||||
color: '#eeeeee',
|
||||
border: '1px solid #da5c2c',
|
||||
fontWeight: 700,
|
||||
},
|
||||
ghost: {
|
||||
background: 'transparent',
|
||||
color: '#eeeeee',
|
||||
border: '1px solid #3a3a3a',
|
||||
fontWeight: 400,
|
||||
},
|
||||
secondary: {
|
||||
background: '#191919',
|
||||
color: '#eeeeee',
|
||||
border: '1px solid #202020',
|
||||
fontWeight: 400,
|
||||
},
|
||||
danger: {
|
||||
background: 'transparent',
|
||||
color: '#ef5350',
|
||||
border: '1px solid #ef5350',
|
||||
fontWeight: 700,
|
||||
},
|
||||
}
|
||||
|
||||
const baseStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
borderRadius: '2px',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
transition: 'background-color 0.1s ease, border-color 0.1s ease, opacity 0.1s ease',
|
||||
outline: 'none',
|
||||
...sizeStyles[size],
|
||||
...variantStyles[variant],
|
||||
...style,
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
disabled={isDisabled}
|
||||
style={baseStyle}
|
||||
className={`flowgit-btn ${className}`}
|
||||
onMouseEnter={(e) => {
|
||||
if (isDisabled) return
|
||||
if (variant === 'primary') {
|
||||
e.currentTarget.style.background = '#e66835'
|
||||
e.currentTarget.style.borderColor = '#e66835'
|
||||
} else if (variant === 'ghost') {
|
||||
e.currentTarget.style.background = '#191919'
|
||||
e.currentTarget.style.borderColor = '#505050'
|
||||
} else if (variant === 'secondary') {
|
||||
e.currentTarget.style.background = '#202020'
|
||||
e.currentTarget.style.borderColor = '#3a3a3a'
|
||||
} else if (variant === 'danger') {
|
||||
e.currentTarget.style.background = 'rgba(239, 83, 80, 0.1)'
|
||||
}
|
||||
props.onMouseEnter?.(e)
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (isDisabled) return
|
||||
const def = variantStyles[variant]
|
||||
e.currentTarget.style.background = def.background as string
|
||||
e.currentTarget.style.borderColor = def.border ? (def.border as string).split(' ')[2] : 'transparent'
|
||||
props.onMouseLeave?.(e)
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
border: '2px solid rgba(238, 238, 238, 0.3)',
|
||||
borderTopColor: '#eeeeee',
|
||||
borderRadius: '50%',
|
||||
animation: 'flowgit-spin 0.6s linear infinite',
|
||||
marginRight: children ? '6px' : 0,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
icon && <span style={{ display: 'inline-flex', alignItems: 'center' }}>{icon}</span>
|
||||
)}
|
||||
{children}
|
||||
{showArrow && !loading && (
|
||||
<span style={{ marginLeft: '4px', display: 'inline-block' }}>→</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
99
src/components/ConfirmModal.tsx
Normal file
99
src/components/ConfirmModal.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import Button from './Button'
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
isDangerous?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export const ConfirmModal: React.FC<ConfirmModalProps> = ({
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
isDangerous = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onCancel])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.75)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#191919',
|
||||
border: `1px solid ${isDangerous ? '#da5c2c' : '#202020'}`,
|
||||
borderRadius: '2px',
|
||||
padding: '24px',
|
||||
width: '420px',
|
||||
maxWidth: '100%',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
boxShadow: 'rgba(0, 0, 0, 0.5) 0px 8px 24px 0px',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: isDangerous ? '#da5c2c' : '#eeeeee',
|
||||
fontSize: '16px',
|
||||
fontWeight: 700,
|
||||
marginBottom: '12px',
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
color: '#b4b4b4',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.6,
|
||||
marginBottom: '24px',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
||||
<Button variant="ghost" size="md" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isDangerous ? 'danger' : 'primary'}
|
||||
size="md"
|
||||
onClick={onConfirm}
|
||||
showArrow
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmModal
|
||||
358
src/components/DiffViewer.tsx
Normal file
358
src/components/DiffViewer.tsx
Normal file
@ -0,0 +1,358 @@
|
||||
import React from 'react'
|
||||
import type { FileDiff, DiffHunk, DiffLine } from '@shared/types/git.types'
|
||||
import FileStatusIcon from './FileStatusIcon'
|
||||
|
||||
export interface DiffViewerProps {
|
||||
diff: FileDiff | null
|
||||
filePath?: string
|
||||
isStaged?: boolean
|
||||
onStageHunk?: (hunkIndex: number) => void
|
||||
onUnstageHunk?: (hunkIndex: number) => void
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export const DiffViewer: React.FC<DiffViewerProps> = ({
|
||||
diff,
|
||||
filePath,
|
||||
isStaged,
|
||||
onStageHunk,
|
||||
onUnstageHunk,
|
||||
isLoading = false,
|
||||
className = '',
|
||||
style,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#111111',
|
||||
color: '#7e7e7e',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '13px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
Loading diff…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!diff) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#0d0d0d',
|
||||
color: '#606060',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '13px',
|
||||
gap: '8px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '24px', opacity: 0.5 }}>▤</span>
|
||||
<span>Select a file to inspect diff</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (diff.isBinary) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#111111',
|
||||
color: '#b4b4b4',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '13px',
|
||||
padding: '24px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#e5c07b', marginBottom: '8px', fontSize: '20px' }}>[BINARY]</span>
|
||||
<div style={{ color: '#eeeeee', fontWeight: 700 }}>Binary file not shown</div>
|
||||
<div style={{ color: '#7e7e7e', fontSize: '12px', marginTop: '4px' }}>
|
||||
{diff.newPath || diff.oldPath || filePath}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (diff.isLarge) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#111111',
|
||||
color: '#b4b4b4',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '13px',
|
||||
padding: '24px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#da5c2c', marginBottom: '8px', fontSize: '20px' }}>[LARGE]</span>
|
||||
<div style={{ color: '#eeeeee', fontWeight: 700 }}>File is too large to display inline</div>
|
||||
<div style={{ color: '#7e7e7e', fontSize: '12px', marginTop: '4px' }}>
|
||||
{diff.additions} additions, {diff.deletions} deletions
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!diff.hunks || diff.hunks.length === 0) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#111111',
|
||||
color: '#7e7e7e',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '13px',
|
||||
padding: '24px',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<span>No visible differences found in file</span>
|
||||
<span style={{ color: '#505050', fontSize: '11px', marginTop: '4px' }}>
|
||||
{diff.newPath || diff.oldPath}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#0d0d0d',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
className={`flowgit-diff-viewer ${className}`}
|
||||
>
|
||||
{/* File Header Bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: '#141414',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 }}>
|
||||
<FileStatusIcon status={diff.status} />
|
||||
<span
|
||||
style={{
|
||||
color: '#eeeeee',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{diff.newPath || diff.oldPath}
|
||||
</span>
|
||||
{diff.oldPath && diff.newPath && diff.oldPath !== diff.newPath && (
|
||||
<span style={{ color: '#606060', fontSize: '11px' }}>
|
||||
(was {diff.oldPath})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: '11px', display: 'inline-flex', gap: '6px' }}>
|
||||
<span style={{ color: '#4caf50' }}>+{diff.additions}</span>
|
||||
<span style={{ color: '#ef5350' }}>-{diff.deletions}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diff Hunks Content */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'auto',
|
||||
userSelect: 'text',
|
||||
}}
|
||||
>
|
||||
{diff.hunks.map((hunk: DiffHunk, hunkIndex: number) => (
|
||||
<div key={hunkIndex} style={{ marginBottom: '16px' }}>
|
||||
{/* Hunk Header */}
|
||||
<div
|
||||
style={{
|
||||
background: '#202020',
|
||||
color: '#b4b4b4',
|
||||
fontSize: '12px',
|
||||
padding: '4px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderTop: '1px solid #2a2a2a',
|
||||
borderBottom: '1px solid #2a2a2a',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<span>{hunk.header}</span>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
{isStaged && onUnstageHunk && (
|
||||
<button
|
||||
onClick={() => onUnstageHunk(hunkIndex)}
|
||||
style={hunkActionBtnStyle}
|
||||
title="Unstage this hunk"
|
||||
>
|
||||
Unstage Hunk
|
||||
</button>
|
||||
)}
|
||||
{!isStaged && onStageHunk && (
|
||||
<button
|
||||
onClick={() => onStageHunk(hunkIndex)}
|
||||
style={hunkActionBtnStyle}
|
||||
title="Stage this hunk"
|
||||
>
|
||||
Stage Hunk
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hunk Lines */}
|
||||
<div style={{ fontSize: '12px', lineHeight: 1.5 }}>
|
||||
{hunk.lines.map((line: DiffLine, lineIdx: number) => {
|
||||
const isAdded = line.type === 'added'
|
||||
const isRemoved = line.type === 'removed'
|
||||
|
||||
const rowBg = isAdded
|
||||
? '#0d1f0d'
|
||||
: isRemoved
|
||||
? '#1f0d0d'
|
||||
: '#111111'
|
||||
|
||||
const textColor = isAdded
|
||||
? '#4caf50'
|
||||
: isRemoved
|
||||
? '#ef5350'
|
||||
: '#eeeeee'
|
||||
|
||||
const symbol = isAdded ? '+' : isRemoved ? '-' : ' '
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lineIdx}
|
||||
style={{
|
||||
display: 'flex',
|
||||
background: rowBg,
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
{/* Old line number */}
|
||||
<div
|
||||
style={{
|
||||
width: '44px',
|
||||
padding: '0 8px',
|
||||
textAlign: 'right',
|
||||
color: '#606060',
|
||||
userSelect: 'none',
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{line.oldLineNumber ?? ''}
|
||||
</div>
|
||||
|
||||
{/* New line number */}
|
||||
<div
|
||||
style={{
|
||||
width: '44px',
|
||||
padding: '0 8px',
|
||||
textAlign: 'right',
|
||||
color: '#606060',
|
||||
userSelect: 'none',
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{line.newLineNumber ?? ''}
|
||||
</div>
|
||||
|
||||
{/* Diff indicator (+/-/space) */}
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
textAlign: 'center',
|
||||
color: textColor,
|
||||
userSelect: 'none',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</div>
|
||||
|
||||
{/* Code line content */}
|
||||
<pre
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: 0,
|
||||
padding: '0 8px',
|
||||
color: textColor,
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '12px',
|
||||
whiteSpace: 'pre',
|
||||
wordBreak: 'normal',
|
||||
}}
|
||||
>
|
||||
{line.content || ' '}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hunkActionBtnStyle: React.CSSProperties = {
|
||||
background: '#141414',
|
||||
border: '1px solid #3a3a3a',
|
||||
borderRadius: '2px',
|
||||
color: '#eeeeee',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
export default DiffViewer
|
||||
93
src/components/EmptyState.tsx
Normal file
93
src/components/EmptyState.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import Button from './Button'
|
||||
|
||||
export interface EmptyStateProps {
|
||||
symbol?: string
|
||||
title: string
|
||||
description?: string
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
actionIcon?: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
symbol = '∅',
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
actionIcon,
|
||||
className = '',
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '32px 20px',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
height: '100%',
|
||||
minHeight: '160px',
|
||||
...style,
|
||||
}}
|
||||
className={`flowgit-empty-state ${className}`}
|
||||
>
|
||||
{symbol && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '28px',
|
||||
color: '#505050',
|
||||
marginBottom: '12px',
|
||||
userSelect: 'none',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
color: '#eeeeee',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
marginBottom: description ? '6px' : '0px',
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{description && (
|
||||
<div
|
||||
style={{
|
||||
color: '#7e7e7e',
|
||||
fontSize: '12px',
|
||||
maxWidth: '360px',
|
||||
lineHeight: 1.6,
|
||||
marginBottom: actionLabel ? '16px' : '0px',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{actionLabel && onAction && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onAction}
|
||||
icon={actionIcon}
|
||||
showArrow
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
76
src/components/FileStatusIcon.tsx
Normal file
76
src/components/FileStatusIcon.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import type { FileStatus } from '@shared/types/git.types'
|
||||
|
||||
export interface FileStatusIconProps {
|
||||
status: FileStatus | string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
showTooltip?: boolean
|
||||
}
|
||||
|
||||
export const FileStatusIcon: React.FC<FileStatusIconProps> = ({
|
||||
status,
|
||||
className = '',
|
||||
style,
|
||||
showTooltip = true,
|
||||
}) => {
|
||||
const getStatusConfig = (
|
||||
st: string
|
||||
): { char: string; color: string; label: string } => {
|
||||
switch (st.toLowerCase()) {
|
||||
case 'modified':
|
||||
case 'm':
|
||||
return { char: 'M', color: '#e5c07b', label: 'Modified' }
|
||||
case 'added':
|
||||
case 'a':
|
||||
return { char: 'A', color: '#4caf50', label: 'Added' }
|
||||
case 'deleted':
|
||||
case 'd':
|
||||
return { char: 'D', color: '#ef5350', label: 'Deleted' }
|
||||
case 'renamed':
|
||||
case 'r':
|
||||
return { char: 'R', color: '#61afef', label: 'Renamed' }
|
||||
case 'copied':
|
||||
return { char: 'C', color: '#61afef', label: 'Copied' }
|
||||
case 'untracked':
|
||||
case 'u':
|
||||
case '?':
|
||||
return { char: 'U', color: '#7e7e7e', label: 'Untracked' }
|
||||
case 'conflicted':
|
||||
case 'conflict':
|
||||
case 'c':
|
||||
return { char: 'C', color: '#da5c2c', label: 'Conflict' }
|
||||
case 'ignored':
|
||||
return { char: 'I', color: '#505050', label: 'Ignored' }
|
||||
default:
|
||||
return { char: '·', color: '#606060', label: st }
|
||||
}
|
||||
}
|
||||
|
||||
const { char, color, label } = getStatusConfig(status)
|
||||
|
||||
return (
|
||||
<span
|
||||
title={showTooltip ? label : undefined}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: color,
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
lineHeight: 1,
|
||||
userSelect: 'none',
|
||||
...style,
|
||||
}}
|
||||
className={`flowgit-file-status ${className}`}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileStatusIcon
|
||||
121
src/components/Input.tsx
Normal file
121
src/components/Input.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import React, { forwardRef, useState } from 'react'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
helperText?: string
|
||||
leftIcon?: React.ReactNode
|
||||
rightIcon?: React.ReactNode
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, helperText, leftIcon, rightIcon, style, className = '', disabled, ...props }, ref) => {
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', width: '100%' }}>
|
||||
{label && (
|
||||
<label
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '12px',
|
||||
color: '#b4b4b4',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
background: '#111111',
|
||||
border: `1px solid ${error ? '#ef5350' : focused ? '#da5c2c' : '#202020'}`,
|
||||
borderRadius: '2px',
|
||||
transition: 'border-color 0.15s ease',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{leftIcon && (
|
||||
<div
|
||||
style={{
|
||||
paddingLeft: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#606060',
|
||||
}}
|
||||
>
|
||||
{leftIcon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
color: '#eeeeee',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '13px',
|
||||
padding: leftIcon || rightIcon ? '8px 10px' : '8px 12px',
|
||||
borderRadius: '2px',
|
||||
...style,
|
||||
}}
|
||||
className={`flowgit-input ${className}`}
|
||||
onFocus={(e) => {
|
||||
setFocused(true)
|
||||
props.onFocus?.(e)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setFocused(false)
|
||||
props.onBlur?.(e)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div
|
||||
style={{
|
||||
paddingRight: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#606060',
|
||||
}}
|
||||
>
|
||||
{rightIcon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error ? (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
color: '#ef5350',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</span>
|
||||
) : helperText ? (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
color: '#7e7e7e',
|
||||
}}
|
||||
>
|
||||
{helperText}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export default Input
|
||||
103
src/components/Panel.tsx
Normal file
103
src/components/Panel.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface PanelProps {
|
||||
title?: React.ReactNode
|
||||
subtitle?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
variant?: 'carbon' | 'graphite' | 'void'
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
accentBorder?: boolean
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export const Panel: React.FC<PanelProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
variant = 'carbon',
|
||||
padding = 'md',
|
||||
accentBorder = false,
|
||||
children,
|
||||
className = '',
|
||||
style,
|
||||
onClick,
|
||||
}) => {
|
||||
const bgMap = {
|
||||
void: '#000000',
|
||||
carbon: '#111111',
|
||||
graphite: '#191919',
|
||||
}
|
||||
|
||||
const paddingMap = {
|
||||
none: '0px',
|
||||
sm: '8px 12px',
|
||||
md: '16px',
|
||||
lg: '24px',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
background: bgMap[variant],
|
||||
border: '1px solid #202020',
|
||||
borderLeft: accentBorder ? '2px solid #da5c2c' : '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
className={`flowgit-panel ${className}`}
|
||||
>
|
||||
{(title || subtitle || actions) && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid #202020',
|
||||
background: variant === 'carbon' ? '#141414' : '#1c1c1c',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{title && (
|
||||
<div
|
||||
style={{
|
||||
color: '#eeeeee',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div
|
||||
style={{
|
||||
color: '#7e7e7e',
|
||||
fontSize: '11px',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{actions && <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>{actions}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: paddingMap[padding], flex: 1, overflow: 'auto' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Panel
|
||||
311
src/components/Sidebar.tsx
Normal file
311
src/components/Sidebar.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useRepoStore } from '../store/repo.store'
|
||||
import { useUiStore } from '../store/ui.store'
|
||||
import type { Repository } from '@shared/types/git.types'
|
||||
|
||||
interface NavItem {
|
||||
id: 'changes' | 'history' | 'graph' | 'branches' | 'stash' | 'remotes' | 'filebrowser'
|
||||
label: string
|
||||
symbol: string
|
||||
badge?: number | string
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { repositories, activeRepoId, setActiveRepo, status } = useRepoStore()
|
||||
const { activeView, setActiveView } = useUiStore()
|
||||
const [repoMenuOpen, setRepoMenuOpen] = useState(false)
|
||||
|
||||
const activeRepo = repositories.find((r) => r.id === activeRepoId)
|
||||
|
||||
const changedCount =
|
||||
(status?.staged.length ?? 0) +
|
||||
(status?.unstaged.length ?? 0) +
|
||||
(status?.untracked.length ?? 0) +
|
||||
(status?.conflicted.length ?? 0)
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
id: 'changes',
|
||||
label: 'Changes',
|
||||
symbol: '±',
|
||||
badge: changedCount > 0 ? changedCount : undefined,
|
||||
},
|
||||
{ id: 'history', label: 'History', symbol: '◉' },
|
||||
{ id: 'graph', label: 'Graph', symbol: '⬡' },
|
||||
{ id: 'branches', label: 'Branches', symbol: '⑂' },
|
||||
{ id: 'stash', label: 'Stash', symbol: '⊞' },
|
||||
{ id: 'remotes', label: 'Remotes', symbol: '⇅' },
|
||||
{ id: 'filebrowser', label: 'File Browser', symbol: '▤' },
|
||||
]
|
||||
|
||||
const handleSelectRepo = (repo: Repository) => {
|
||||
setActiveRepo(repo)
|
||||
setRepoMenuOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
style={{
|
||||
width: '240px',
|
||||
background: '#111111',
|
||||
borderRight: '1px solid #202020',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
userSelect: 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
className="flowgit-sidebar"
|
||||
>
|
||||
{/* Top: Repo Switcher */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
borderBottom: '1px solid #202020',
|
||||
background: '#0d0d0d',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setRepoMenuOpen(!repoMenuOpen)}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: repoMenuOpen ? '#191919' : 'transparent',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
padding: '6px 10px',
|
||||
color: '#eeeeee',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
title={activeRepo?.path ?? 'Select Repository'}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', overflow: 'hidden' }}>
|
||||
<span style={{ color: '#da5c2c', fontSize: '13px' }}>◇</span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{activeRepo?.name ?? 'No repo'}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ color: '#606060', fontSize: '10px' }}>▼</span>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{repoMenuOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 4px)',
|
||||
left: '14px',
|
||||
right: '14px',
|
||||
background: '#191919',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
zIndex: 100,
|
||||
boxShadow: 'rgba(0, 0, 0, 0.5) 0px 8px 16px',
|
||||
maxHeight: '260px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
fontSize: '11px',
|
||||
color: '#7e7e7e',
|
||||
borderBottom: '1px solid #202020',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Recent Repositories
|
||||
</div>
|
||||
{repositories.map((repo) => (
|
||||
<button
|
||||
key={repo.id}
|
||||
onClick={() => handleSelectRepo(repo)}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: repo.id === activeRepoId ? '#202020' : 'transparent',
|
||||
border: 'none',
|
||||
borderLeft: repo.id === activeRepoId ? '2px solid #da5c2c' : '2px solid transparent',
|
||||
padding: '8px 10px',
|
||||
color: repo.id === activeRepoId ? '#eeeeee' : '#b4b4b4',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '12px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (repo.id !== activeRepoId) e.currentTarget.style.background = '#141414'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (repo.id !== activeRepoId) e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: repo.id === activeRepoId ? 700 : 400 }}>{repo.name}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#606060',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{repo.path}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div style={{ borderTop: '1px solid #202020', padding: '4px' }}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRepoMenuOpen(false)
|
||||
navigate('/welcome')
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
padding: '6px 8px',
|
||||
color: '#da5c2c',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ Open / Clone another repo →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation items */}
|
||||
<nav style={{ flex: 1, padding: '10px 0', overflowY: 'auto' }}>
|
||||
{navItems.map((item) => {
|
||||
const isActive = activeView === item.id
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setActiveView(item.id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: isActive ? '#191919' : 'transparent',
|
||||
border: 'none',
|
||||
borderLeft: `2px solid ${isActive ? '#da5c2c' : 'transparent'}`,
|
||||
color: isActive ? '#eeeeee' : '#b4b4b4',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '13px',
|
||||
padding: '9px 16px',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'background-color 0.1s ease, color 0.1s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background = '#202020'
|
||||
e.currentTarget.style.color = '#eeeeee'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = '#b4b4b4'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
width: '16px',
|
||||
textAlign: 'center',
|
||||
color: isActive ? '#da5c2c' : '#7e7e7e',
|
||||
}}
|
||||
>
|
||||
{item.symbol}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
{item.badge !== undefined && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: isActive ? '#da5c2c' : '#7e7e7e',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom strip: Settings gear & Quick Status */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid #202020',
|
||||
padding: '10px 16px',
|
||||
background: '#0d0d0d',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#7e7e7e',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
padding: 0,
|
||||
}}
|
||||
title="App Settings"
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = '#eeeeee')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = '#7e7e7e')}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
|
||||
<span style={{ fontSize: '11px', color: '#505050' }}>v1.0.0</span>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar
|
||||
75
src/components/Spinner.tsx
Normal file
75
src/components/Spinner.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface SpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg' | number
|
||||
color?: string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Spinner: React.FC<SpinnerProps> = ({
|
||||
size = 'md',
|
||||
color = '#da5c2c',
|
||||
className = '',
|
||||
style,
|
||||
label,
|
||||
}) => {
|
||||
const pixelSize =
|
||||
typeof size === 'number'
|
||||
? size
|
||||
: size === 'sm'
|
||||
? 14
|
||||
: size === 'lg'
|
||||
? 32
|
||||
: 20
|
||||
|
||||
const strokeWidth = pixelSize <= 16 ? 2 : 2.5
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
...style,
|
||||
}}
|
||||
className={`flowgit-spinner ${className}`}
|
||||
>
|
||||
<svg
|
||||
width={pixelSize}
|
||||
height={pixelSize}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
animation: 'flowgit-spin 0.75s linear infinite',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeOpacity="0.2"
|
||||
/>
|
||||
<path
|
||||
d="M12 3C7.02944 3 3 7.02944 3 12"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="square"
|
||||
/>
|
||||
</svg>
|
||||
{label && (
|
||||
<span style={{ fontSize: '12px', color: '#b4b4b4' }}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Spinner
|
||||
147
src/components/SplitPane.tsx
Normal file
147
src/components/SplitPane.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface SplitPaneProps {
|
||||
direction?: 'horizontal' | 'vertical'
|
||||
initialSplit?: number // percentage (0 to 100) or pixels if > 100
|
||||
minSize?: number // pixels
|
||||
maxSize?: number // pixels
|
||||
first: React.ReactNode
|
||||
second: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
onResize?: (size: number) => void
|
||||
}
|
||||
|
||||
export const SplitPane: React.FC<SplitPaneProps> = ({
|
||||
direction = 'horizontal',
|
||||
initialSplit = 50,
|
||||
minSize = 150,
|
||||
maxSize,
|
||||
first,
|
||||
second,
|
||||
className = '',
|
||||
style,
|
||||
onResize,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isHorizontal = direction === 'horizontal'
|
||||
const [split, setSplit] = useState<number>(initialSplit)
|
||||
const isDraggingRef = useRef(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
isDraggingRef.current = true
|
||||
setIsDragging(true)
|
||||
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [isHorizontal])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDraggingRef.current || !containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
let newSize: number
|
||||
|
||||
if (isHorizontal) {
|
||||
newSize = e.clientX - rect.left
|
||||
} else {
|
||||
newSize = e.clientY - rect.top
|
||||
}
|
||||
|
||||
const totalSize = isHorizontal ? rect.width : rect.height
|
||||
if (newSize < minSize) newSize = minSize
|
||||
if (maxSize && newSize > maxSize) newSize = maxSize
|
||||
if (newSize > totalSize - minSize) newSize = totalSize - minSize
|
||||
|
||||
setSplit(newSize)
|
||||
onResize?.(newSize)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (isDraggingRef.current) {
|
||||
isDraggingRef.current = false
|
||||
setIsDragging(false)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isHorizontal, minSize, maxSize, onResize])
|
||||
|
||||
const firstPaneStyle: React.CSSProperties = isHorizontal
|
||||
? typeof split === 'number' && split <= 100
|
||||
? { width: `${split}%`, flexShrink: 0 }
|
||||
: { width: `${split}px`, flexShrink: 0 }
|
||||
: typeof split === 'number' && split <= 100
|
||||
? { height: `${split}%`, flexShrink: 0 }
|
||||
: { height: `${split}px`, flexShrink: 0 }
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: isHorizontal ? 'row' : 'column',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
...style,
|
||||
}}
|
||||
className={`flowgit-split-pane ${className}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...firstPaneStyle,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{first}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: isDragging ? '#da5c2c' : '#202020',
|
||||
cursor: isHorizontal ? 'col-resize' : 'row-resize',
|
||||
width: isHorizontal ? '4px' : '100%',
|
||||
height: isHorizontal ? '100%' : '4px',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
transition: isDragging ? 'none' : 'background-color 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isDragging) e.currentTarget.style.backgroundColor = '#3a3a3a'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isDragging) e.currentTarget.style.backgroundColor = '#202020'
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{second}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SplitPane
|
||||
119
src/components/Tag.tsx
Normal file
119
src/components/Tag.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import React from 'react'
|
||||
|
||||
export interface TagProps {
|
||||
label: string
|
||||
variant?: 'branch' | 'tag' | 'remote' | 'head' | 'default' | 'stash'
|
||||
color?: string
|
||||
icon?: React.ReactNode
|
||||
size?: 'sm' | 'md'
|
||||
onRemove?: () => void
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export const Tag: React.FC<TagProps> = ({
|
||||
label,
|
||||
variant = 'default',
|
||||
color,
|
||||
icon,
|
||||
size = 'sm',
|
||||
onRemove,
|
||||
className = '',
|
||||
style,
|
||||
}) => {
|
||||
const getVariantStyles = (): React.CSSProperties => {
|
||||
switch (variant) {
|
||||
case 'head':
|
||||
return {
|
||||
background: 'rgba(218, 92, 44, 0.15)',
|
||||
color: '#da5c2c',
|
||||
border: '1px solid #da5c2c',
|
||||
}
|
||||
case 'branch':
|
||||
return {
|
||||
background: '#141c24',
|
||||
color: '#61afef',
|
||||
border: '1px solid #1e2c3a',
|
||||
}
|
||||
case 'tag':
|
||||
return {
|
||||
background: '#242114',
|
||||
color: '#e5c07b',
|
||||
border: '1px solid #3d351e',
|
||||
}
|
||||
case 'remote':
|
||||
return {
|
||||
background: '#191919',
|
||||
color: '#b4b4b4',
|
||||
border: '1px solid #3a3a3a',
|
||||
}
|
||||
case 'stash':
|
||||
return {
|
||||
background: '#1a1924',
|
||||
color: '#c678dd',
|
||||
border: '1px solid #2e2a42',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
background: '#191919',
|
||||
color: '#d9d9d9',
|
||||
border: '1px solid #202020',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isSmall = size === 'sm'
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: isSmall ? '1px 8px' : '3px 10px',
|
||||
borderRadius: '9999px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: isSmall ? '11px' : '12px',
|
||||
fontWeight: variant === 'head' ? 700 : 400,
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.4,
|
||||
userSelect: 'none',
|
||||
...(color
|
||||
? {
|
||||
background: `${color}18`,
|
||||
color: color,
|
||||
border: `1px solid ${color}40`,
|
||||
}
|
||||
: getVariantStyles()),
|
||||
...style,
|
||||
}}
|
||||
className={`flowgit-tag ${className}`}
|
||||
>
|
||||
{icon && <span style={{ display: 'inline-flex', alignItems: 'center' }}>{icon}</span>}
|
||||
<span>{label}</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove()
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
marginLeft: '2px',
|
||||
fontSize: '11px',
|
||||
lineHeight: 1,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tag
|
||||
256
src/components/TitleBar.tsx
Normal file
256
src/components/TitleBar.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useRepoStore } from '../store/repo.store'
|
||||
import Tag from './Tag'
|
||||
|
||||
export interface TitleBarProps {
|
||||
showRepoSwitcher?: boolean
|
||||
}
|
||||
|
||||
interface DragCSSProperties extends React.CSSProperties {
|
||||
WebkitAppRegion?: 'drag' | 'no-drag'
|
||||
}
|
||||
|
||||
export const TitleBar: React.FC<TitleBarProps> = ({ showRepoSwitcher = true }) => {
|
||||
const navigate = useNavigate()
|
||||
const { repositories, activeRepoId, status } = useRepoStore()
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
|
||||
const activeRepo = repositories.find((r) => r.id === activeRepoId)
|
||||
|
||||
useEffect(() => {
|
||||
// Check initial maximized state if available
|
||||
window.flowgit?.window?.isMaximized?.().then((max) => {
|
||||
setIsMaximized(max)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.flowgit?.window?.minimize?.()
|
||||
}
|
||||
|
||||
const handleMaximize = async () => {
|
||||
try {
|
||||
await window.flowgit?.window?.maximize?.()
|
||||
const max = await window.flowgit?.window?.isMaximized?.()
|
||||
setIsMaximized(!!max)
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
if (window.flowgit?.window?.close) {
|
||||
window.flowgit.window.close()
|
||||
} else {
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
|
||||
const containerStyle: DragCSSProperties = {
|
||||
height: '40px',
|
||||
background: '#000000',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 0 0 16px',
|
||||
WebkitAppRegion: 'drag',
|
||||
flexShrink: 0,
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
userSelect: 'none',
|
||||
zIndex: 50,
|
||||
}
|
||||
|
||||
const noDragStyle: DragCSSProperties = {
|
||||
WebkitAppRegion: 'no-drag',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={containerStyle} className="flowgit-titlebar">
|
||||
{/* Left: App Logo & Brand */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
...noDragStyle,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => navigate('/welcome')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
title="Home / Welcome"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: '#da5c2c',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
FlowGit
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{activeRepo && (
|
||||
<>
|
||||
<span style={{ color: '#3a3a3a', fontSize: '13px' }}>/</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#eeeeee',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
maxWidth: '200px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{activeRepo.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: Repository Context & Branch Status */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
overflow: 'hidden',
|
||||
padding: '0 12px',
|
||||
}}
|
||||
>
|
||||
{status?.branch && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
...noDragStyle,
|
||||
}}
|
||||
>
|
||||
<Tag
|
||||
label={status.branch}
|
||||
variant={status.detachedHead ? 'head' : 'branch'}
|
||||
size="sm"
|
||||
/>
|
||||
{(status.ahead > 0 || status.behind > 0) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: '#b4b4b4',
|
||||
display: 'inline-flex',
|
||||
gap: '4px',
|
||||
}}
|
||||
>
|
||||
{status.ahead > 0 && <span style={{ color: '#4caf50' }}>↑{status.ahead}</span>}
|
||||
{status.behind > 0 && <span style={{ color: '#ef5350' }}>↓{status.behind}</span>}
|
||||
</span>
|
||||
)}
|
||||
{status.mergeInProgress && (
|
||||
<span style={{ color: '#e5c07b', fontSize: '11px' }}>[MERGING]</span>
|
||||
)}
|
||||
{status.rebaseInProgress && (
|
||||
<span style={{ color: '#e5c07b', fontSize: '11px' }}>[REBASING]</span>
|
||||
)}
|
||||
{status.conflicted && status.conflicted.length > 0 && (
|
||||
<span style={{ color: '#da5c2c', fontSize: '11px', fontWeight: 700 }}>
|
||||
[{status.conflicted.length} CONFLICT{status.conflicted.length > 1 ? 'S' : ''}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Window Controls in Axiom dark style */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
height: '100%',
|
||||
...noDragStyle,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
style={windowBtnStyle}
|
||||
title="Minimize"
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#202020')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<svg width="10" height="1" viewBox="0 0 10 1" fill="none">
|
||||
<rect width="10" height="1" fill="#b4b4b4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
style={windowBtnStyle}
|
||||
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#202020')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path
|
||||
d="M2 0H10V8H8V10H0V2H2V0ZM2 2H8V0.8H2.8V2H2ZM1 3H7V9H1V3Z"
|
||||
fill="#b4b4b4"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none">
|
||||
<rect x="0.5" y="0.5" width="8" height="8" stroke="#b4b4b4" strokeWidth="1" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{ ...windowBtnStyle, paddingRight: '14px', paddingLeft: '14px' }}
|
||||
title="Close"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#da5c2c'
|
||||
const svg = e.currentTarget.querySelector('svg path') as SVGPathElement | null
|
||||
if (svg) svg.setAttribute('stroke', '#ffffff')
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
const svg = e.currentTarget.querySelector('svg path') as SVGPathElement | null
|
||||
if (svg) svg.setAttribute('stroke', '#b4b4b4')
|
||||
}}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<path d="M1 1L9 9M9 1L1 9" stroke="#b4b4b4" strokeWidth="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const windowBtnStyle: React.CSSProperties = {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
width: '42px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.1s ease',
|
||||
outline: 'none',
|
||||
}
|
||||
|
||||
export default TitleBar
|
||||
139
src/components/ToastContainer.tsx
Normal file
139
src/components/ToastContainer.tsx
Normal file
@ -0,0 +1,139 @@
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
type: 'success' | 'error' | 'info' | 'warning'
|
||||
title: string
|
||||
message?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export interface ToastContainerProps {
|
||||
toasts: Toast[]
|
||||
onDismiss: (id: string) => void
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<string, { border: string; symbol: string; text: string }> = {
|
||||
success: { border: '#4caf50', symbol: '✓', text: '#4caf50' },
|
||||
error: { border: '#da5c2c', symbol: '✗', text: '#da5c2c' },
|
||||
info: { border: '#61afef', symbol: 'i', text: '#61afef' },
|
||||
warning: { border: '#e5c07b', symbol: '!', text: '#e5c07b' },
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
|
||||
useEffect(() => {
|
||||
const duration = toast.duration ?? 4000
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss(toast.id)
|
||||
}, duration)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toast.id, toast.duration, onDismiss])
|
||||
|
||||
const config = TYPE_CONFIG[toast.type] ?? TYPE_CONFIG.info
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#191919',
|
||||
border: '1px solid #202020',
|
||||
borderLeft: `2px solid ${config.border}`,
|
||||
borderRadius: '2px',
|
||||
padding: '12px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '12px',
|
||||
minWidth: '280px',
|
||||
maxWidth: '380px',
|
||||
boxShadow: 'rgba(0, 0, 0, 0.25) 0px 4px 12px 0px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
animation: 'flowgit-toast-in 0.18s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: config.text,
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
lineHeight: '18px',
|
||||
}}
|
||||
>
|
||||
[{config.symbol}]
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
color: '#eeeeee',
|
||||
fontSize: '13px',
|
||||
fontWeight: 700,
|
||||
lineHeight: '18px',
|
||||
}}
|
||||
>
|
||||
{toast.title}
|
||||
</div>
|
||||
{toast.message && (
|
||||
<div
|
||||
style={{
|
||||
color: '#b4b4b4',
|
||||
fontSize: '12px',
|
||||
marginTop: '4px',
|
||||
lineHeight: 1.5,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDismiss(toast.id)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#606060',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
lineHeight: '18px',
|
||||
padding: '0 2px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onDismiss }) => {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes flowgit-toast-in {
|
||||
from { transform: translateX(30px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
zIndex: 99999,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} style={{ pointerEvents: 'auto' }}>
|
||||
<ToastItem toast={toast} onDismiss={onDismiss} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToastContainer
|
||||
106
src/components/Tooltip.tsx
Normal file
106
src/components/Tooltip.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
|
||||
export interface TooltipProps {
|
||||
content: React.ReactNode
|
||||
position?: 'top' | 'bottom' | 'left' | 'right'
|
||||
delay?: number
|
||||
children: React.ReactElement
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
content,
|
||||
position = 'top',
|
||||
delay = 200,
|
||||
children,
|
||||
className = '',
|
||||
style,
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const timerRef = useRef<number | null>(null)
|
||||
|
||||
const show = () => {
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setVisible(true)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
const getPositionStyles = (): React.CSSProperties => {
|
||||
switch (position) {
|
||||
case 'bottom':
|
||||
return {
|
||||
top: 'calc(100% + 6px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}
|
||||
case 'left':
|
||||
return {
|
||||
right: 'calc(100% + 6px)',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}
|
||||
case 'right':
|
||||
return {
|
||||
left: 'calc(100% + 6px)',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}
|
||||
case 'top':
|
||||
default:
|
||||
return {
|
||||
bottom: 'calc(100% + 6px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
...style,
|
||||
}}
|
||||
className={`flowgit-tooltip-wrapper ${className}`}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
>
|
||||
{children}
|
||||
{visible && content && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 9999,
|
||||
background: '#191919',
|
||||
border: '1px solid #202020',
|
||||
color: '#eeeeee',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '2px',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none',
|
||||
boxShadow: 'rgba(0, 0, 0, 0.4) 0px 4px 10px',
|
||||
...getPositionStyles(),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
165
src/design/tokens.css
Normal file
165
src/design/tokens.css
Normal file
@ -0,0 +1,165 @@
|
||||
/* ============================================================
|
||||
FlowGit Design Tokens — derived from DESIGN.md (Axiom system)
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
|
||||
|
||||
@theme {
|
||||
/* ── Colors ── */
|
||||
--color-void: #000000;
|
||||
--color-carbon: #111111;
|
||||
--color-graphite: #191919;
|
||||
--color-iron: #202020;
|
||||
--color-slate: #3a3a3a;
|
||||
--color-pewter: #505050;
|
||||
--color-steel: #606060;
|
||||
--color-ash: #7e7e7e;
|
||||
--color-fog: #b4b4b4;
|
||||
--color-chalk: #d9d9d9;
|
||||
--color-paper: #eeeeee;
|
||||
--color-ember: #da5c2c;
|
||||
|
||||
/* Semantic diff colors (muted, design-system-safe) */
|
||||
--color-added: #1a3a1a;
|
||||
--color-added-text: #4caf50;
|
||||
--color-removed: #3a1a1a;
|
||||
--color-removed-text: #ef5350;
|
||||
|
||||
/* ── Typography ── */
|
||||
--font-mono: 'BerkeleyMono', 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
|
||||
/* ── Type Scale ── */
|
||||
--text-caption: 12px;
|
||||
--text-body: 14px;
|
||||
--text-sm: 13px;
|
||||
--text-heading-sm: 18px;
|
||||
--text-heading: 20px;
|
||||
--text-heading-lg: 24px;
|
||||
--text-display: 32px;
|
||||
|
||||
/* ── Font Weights ── */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* ── Spacing (8px base) ── */
|
||||
--spacing-1: 4px;
|
||||
--spacing-2: 8px;
|
||||
--spacing-3: 12px;
|
||||
--spacing-4: 16px;
|
||||
--spacing-5: 20px;
|
||||
--spacing-6: 24px;
|
||||
--spacing-8: 32px;
|
||||
--spacing-10: 40px;
|
||||
--spacing-12: 48px;
|
||||
--spacing-16: 64px;
|
||||
--spacing-20: 80px;
|
||||
--spacing-24: 96px;
|
||||
|
||||
/* ── Border Radius ── */
|
||||
--radius-none: 0px;
|
||||
--radius-sm: 2px; /* cards, buttons, inputs, panels */
|
||||
--radius-full: 9999px; /* icon badges, pill tags */
|
||||
|
||||
/* ── Shadows ── */
|
||||
--shadow-subtle: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
|
||||
|
||||
/* ── Layout ── */
|
||||
--layout-sidebar-width: 240px;
|
||||
--layout-panel-min: 280px;
|
||||
--layout-titlebar-height: 40px;
|
||||
}
|
||||
|
||||
/* ── CSS Custom Properties (for non-Tailwind usage) ── */
|
||||
:root {
|
||||
--color-void: #000000;
|
||||
--color-carbon: #111111;
|
||||
--color-graphite: #191919;
|
||||
--color-iron: #202020;
|
||||
--color-slate: #3a3a3a;
|
||||
--color-pewter: #505050;
|
||||
--color-steel: #606060;
|
||||
--color-ash: #7e7e7e;
|
||||
--color-fog: #b4b4b4;
|
||||
--color-chalk: #d9d9d9;
|
||||
--color-paper: #eeeeee;
|
||||
--color-ember: #da5c2c;
|
||||
|
||||
--font-mono: 'BerkeleyMono', 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
|
||||
--radius-sm: 2px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--layout-sidebar-width: 240px;
|
||||
--layout-titlebar-height: 40px;
|
||||
}
|
||||
|
||||
/* ── Global Reset & Base ── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-void);
|
||||
color: var(--color-paper);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.71;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Allow text selection in content areas */
|
||||
.selectable {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
/* ── Scrollbar Styling ── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-void);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-slate);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-pewter);
|
||||
}
|
||||
|
||||
/* ── Focus Indicators ── */
|
||||
:focus-visible {
|
||||
outline: 1px solid var(--color-ember);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ── Utility Classes ── */
|
||||
.font-mono { font-family: var(--font-mono); }
|
||||
.font-sans { font-family: var(--font-sans); }
|
||||
.text-paper { color: var(--color-paper); }
|
||||
.text-fog { color: var(--color-fog); }
|
||||
.text-ash { color: var(--color-ash); }
|
||||
.text-steel { color: var(--color-steel); }
|
||||
.text-ember { color: var(--color-ember); }
|
||||
.bg-void { background-color: var(--color-void); }
|
||||
.bg-carbon { background-color: var(--color-carbon); }
|
||||
.bg-graphite { background-color: var(--color-graphite); }
|
||||
.bg-iron { background-color: var(--color-iron); }
|
||||
.border-iron { border-color: var(--color-iron); }
|
||||
.border-slate { border-color: var(--color-slate); }
|
||||
13
src/index.html
Normal file
13
src/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:;" />
|
||||
<title>FlowGit</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './design/tokens.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
69
src/store/repo.store.ts
Normal file
69
src/store/repo.store.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
import type { Repository } from '@shared/types/git.types'
|
||||
import type { RepoStatus } from '@shared/types/git.types'
|
||||
|
||||
interface RepoState {
|
||||
// Repository list
|
||||
repositories: Repository[]
|
||||
activeRepoId: string | null
|
||||
activeRepoPath: string | null
|
||||
|
||||
// Current repo state
|
||||
status: RepoStatus | null
|
||||
isLoadingStatus: boolean
|
||||
|
||||
// Git detection
|
||||
gitFound: boolean
|
||||
gitPath: string | null
|
||||
gitVersion: string | null
|
||||
|
||||
// Actions
|
||||
setRepositories: (repos: Repository[]) => void
|
||||
setActiveRepo: (repo: Repository | null) => void
|
||||
setStatus: (status: RepoStatus | null) => void
|
||||
setLoadingStatus: (loading: boolean) => void
|
||||
setGitInfo: (found: boolean, path: string | null, version: string | null) => void
|
||||
addRepository: (repo: Repository) => void
|
||||
removeRepository: (id: string) => void
|
||||
}
|
||||
|
||||
export const useRepoStore = create<RepoState>()(
|
||||
immer((set) => ({
|
||||
repositories: [],
|
||||
activeRepoId: null,
|
||||
activeRepoPath: null,
|
||||
status: null,
|
||||
isLoadingStatus: false,
|
||||
gitFound: false,
|
||||
gitPath: null,
|
||||
gitVersion: null,
|
||||
|
||||
setRepositories: (repos) => set((state) => { state.repositories = repos }),
|
||||
setActiveRepo: (repo) => set((state) => {
|
||||
state.activeRepoId = repo?.id ?? null
|
||||
state.activeRepoPath = repo?.path ?? null
|
||||
state.status = null
|
||||
}),
|
||||
setStatus: (status) => set((state) => { state.status = status }),
|
||||
setLoadingStatus: (loading) => set((state) => { state.isLoadingStatus = loading }),
|
||||
setGitInfo: (found, path, version) => set((state) => {
|
||||
state.gitFound = found
|
||||
state.gitPath = path
|
||||
state.gitVersion = version
|
||||
}),
|
||||
addRepository: (repo) => set((state) => {
|
||||
const exists = state.repositories.findIndex(r => r.id === repo.id)
|
||||
if (exists >= 0) state.repositories[exists] = repo
|
||||
else state.repositories.unshift(repo)
|
||||
}),
|
||||
removeRepository: (id) => set((state) => {
|
||||
state.repositories = state.repositories.filter(r => r.id !== id)
|
||||
if (state.activeRepoId === id) {
|
||||
state.activeRepoId = null
|
||||
state.activeRepoPath = null
|
||||
state.status = null
|
||||
}
|
||||
}),
|
||||
}))
|
||||
)
|
||||
105
src/store/ui.store.ts
Normal file
105
src/store/ui.store.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { create } from 'zustand'
|
||||
import { immer } from 'zustand/middleware/immer'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
type: ToastType
|
||||
title: string
|
||||
message?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
type ActiveView = 'changes' | 'history' | 'graph' | 'branches' | 'stash' | 'remotes' | 'filebrowser'
|
||||
|
||||
interface UiState {
|
||||
// Toast notifications
|
||||
toasts: Toast[]
|
||||
|
||||
// Active view in the repo workspace
|
||||
activeView: ActiveView
|
||||
|
||||
// Selected items
|
||||
selectedCommitHash: string | null
|
||||
selectedFilePath: string | null
|
||||
|
||||
// Diff layout
|
||||
diffLayout: 'split' | 'unified'
|
||||
|
||||
// Sidebar collapsed
|
||||
sidebarCollapsed: boolean
|
||||
|
||||
// Modals
|
||||
confirmModal: {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText: string
|
||||
isDangerous: boolean
|
||||
onConfirm: (() => void) | null
|
||||
}
|
||||
|
||||
// Actions
|
||||
showToast: (toast: Omit<Toast, 'id'>) => void
|
||||
dismissToast: (id: string) => void
|
||||
setActiveView: (view: ActiveView) => void
|
||||
setSelectedCommit: (hash: string | null) => void
|
||||
setSelectedFile: (path: string | null) => void
|
||||
setDiffLayout: (layout: 'split' | 'unified') => void
|
||||
setSidebarCollapsed: (collapsed: boolean) => void
|
||||
openConfirmModal: (opts: { title: string; message: string; confirmText: string; isDangerous?: boolean; onConfirm: () => void }) => void
|
||||
closeConfirmModal: () => void
|
||||
}
|
||||
|
||||
let toastId = 0
|
||||
|
||||
export const useUiStore = create<UiState>()(
|
||||
immer((set) => ({
|
||||
toasts: [],
|
||||
activeView: 'changes',
|
||||
selectedCommitHash: null,
|
||||
selectedFilePath: null,
|
||||
diffLayout: 'split',
|
||||
sidebarCollapsed: false,
|
||||
confirmModal: {
|
||||
open: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: 'Confirm',
|
||||
isDangerous: false,
|
||||
onConfirm: null,
|
||||
},
|
||||
|
||||
showToast: (toast) => set((state) => {
|
||||
state.toasts.push({ ...toast, id: String(++toastId) })
|
||||
// Auto-dismiss after duration
|
||||
}),
|
||||
|
||||
dismissToast: (id) => set((state) => {
|
||||
state.toasts = state.toasts.filter(t => t.id !== id)
|
||||
}),
|
||||
|
||||
setActiveView: (view) => set((state) => { state.activeView = view }),
|
||||
setSelectedCommit: (hash) => set((state) => { state.selectedCommitHash = hash }),
|
||||
setSelectedFile: (path) => set((state) => { state.selectedFilePath = path }),
|
||||
setDiffLayout: (layout) => set((state) => { state.diffLayout = layout }),
|
||||
setSidebarCollapsed: (collapsed) => set((state) => { state.sidebarCollapsed = collapsed }),
|
||||
|
||||
openConfirmModal: (opts) => set((state) => {
|
||||
state.confirmModal = {
|
||||
open: true,
|
||||
title: opts.title,
|
||||
message: opts.message,
|
||||
confirmText: opts.confirmText,
|
||||
isDangerous: opts.isDangerous ?? false,
|
||||
onConfirm: opts.onConfirm,
|
||||
}
|
||||
}),
|
||||
|
||||
closeConfirmModal: () => set((state) => {
|
||||
state.confirmModal.open = false
|
||||
state.confirmModal.onConfirm = null
|
||||
}),
|
||||
}))
|
||||
)
|
||||
13
src/types/global.d.ts
vendored
Normal file
13
src/types/global.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
import type { FlowGitApi } from '@shared/types/ipc.types'
|
||||
|
||||
declare module 'react' {
|
||||
interface CSSProperties {
|
||||
WebkitAppRegion?: 'drag' | 'no-drag'
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
flowgit: FlowGitApi
|
||||
}
|
||||
}
|
||||
669
src/views/Repository/Branches/BranchesView.tsx
Normal file
669
src/views/Repository/Branches/BranchesView.tsx
Normal file
@ -0,0 +1,669 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useUiStore } from '../../../store/ui.store'
|
||||
import Button from '../../../components/Button'
|
||||
import Input from '../../../components/Input'
|
||||
import Tag from '../../../components/Tag'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import type { Branch, Tag as GitTag, Remote } from '@shared/types/git.types'
|
||||
|
||||
export interface BranchesViewProps {
|
||||
repoPath: string
|
||||
}
|
||||
|
||||
export const BranchesView: React.FC<BranchesViewProps> = ({ repoPath }) => {
|
||||
const { showToast, openConfirmModal } = useUiStore()
|
||||
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [tags, setTags] = useState<GitTag[]>([])
|
||||
const [remotes, setRemotes] = useState<Remote[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
// New Branch modal
|
||||
const [newBranchModal, setNewBranchModal] = useState(false)
|
||||
const [newBranchName, setNewBranchName] = useState('')
|
||||
const [fromRef, setFromRef] = useState('')
|
||||
|
||||
// Rename modal
|
||||
const [renameBranchModal, setRenameBranchModal] = useState<Branch | null>(null)
|
||||
const [renamedName, setRenamedName] = useState('')
|
||||
|
||||
// New Tag modal
|
||||
const [newTagModal, setNewTagModal] = useState(false)
|
||||
const [newTagName, setNewTagName] = useState('')
|
||||
const [tagRef, setTagRef] = useState('')
|
||||
const [tagMessage, setTagMessage] = useState('')
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!repoPath) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const [branchRes, tagRes, remoteRes] = await Promise.all([
|
||||
window.flowgit.branch.list(repoPath),
|
||||
window.flowgit.tag.list(repoPath),
|
||||
window.flowgit.remote.list(repoPath),
|
||||
])
|
||||
|
||||
if (branchRes.success && branchRes.data) {
|
||||
setBranches(branchRes.data as Branch[])
|
||||
}
|
||||
if (tagRes.success && tagRes.data) {
|
||||
setTags(tagRes.data as GitTag[])
|
||||
}
|
||||
if (remoteRes.success && remoteRes.data) {
|
||||
setRemotes(remoteRes.data as Remote[])
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error loading branches',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [repoPath, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// Branch operations
|
||||
const handleSwitchBranch = async (b: Branch) => {
|
||||
if (b.isCurrent) return
|
||||
try {
|
||||
const res = await window.flowgit.branch.switch(repoPath, b.name)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Switched to branch "${b.name}"` })
|
||||
loadData()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Checkout failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMergeBranch = (b: Branch) => {
|
||||
openConfirmModal({
|
||||
title: 'Merge Branch',
|
||||
message: `Merge "${b.name}" into current branch?`,
|
||||
confirmText: 'Merge',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.branch.merge(repoPath, b.name)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Merged "${b.name}" successfully` })
|
||||
loadData()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Merge failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleRebaseBranch = (b: Branch) => {
|
||||
openConfirmModal({
|
||||
title: 'Rebase Onto Branch',
|
||||
message: `Rebase current branch onto "${b.name}"?`,
|
||||
confirmText: 'Rebase',
|
||||
isDangerous: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.branch.rebase(repoPath, b.name)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Rebased onto "${b.name}"` })
|
||||
loadData()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Rebase failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteBranch = (b: Branch) => {
|
||||
openConfirmModal({
|
||||
title: 'Delete Branch',
|
||||
message: `Are you sure you want to delete branch "${b.name}"?`,
|
||||
confirmText: 'Delete Branch',
|
||||
isDangerous: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.branch.delete(repoPath, b.name, true)
|
||||
if (res.success) {
|
||||
showToast({ type: 'info', title: `Deleted branch "${b.name}"` })
|
||||
loadData()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Delete failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateBranch = async () => {
|
||||
if (!newBranchName.trim()) return
|
||||
try {
|
||||
const res = await window.flowgit.branch.create(
|
||||
repoPath,
|
||||
newBranchName.trim(),
|
||||
fromRef.trim() || undefined
|
||||
)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Created branch "${newBranchName.trim()}"` })
|
||||
setNewBranchModal(false)
|
||||
setNewBranchName('')
|
||||
setFromRef('')
|
||||
loadData()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Failed to create branch', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleRenameBranch = async () => {
|
||||
if (!renameBranchModal || !renamedName.trim()) return
|
||||
try {
|
||||
const res = await window.flowgit.branch.rename(
|
||||
repoPath,
|
||||
renameBranchModal.name,
|
||||
renamedName.trim()
|
||||
)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Renamed to "${renamedName.trim()}"` })
|
||||
setRenameBranchModal(null)
|
||||
setRenamedName('')
|
||||
loadData()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Rename failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
// Tag operations
|
||||
const handleCreateTag = async () => {
|
||||
if (!newTagName.trim()) return
|
||||
try {
|
||||
const res = await window.flowgit.tag.create(
|
||||
repoPath,
|
||||
newTagName.trim(),
|
||||
tagRef.trim() || 'HEAD',
|
||||
tagMessage.trim() || undefined
|
||||
)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Created tag "${newTagName.trim()}"` })
|
||||
setNewTagModal(false)
|
||||
setNewTagName('')
|
||||
setTagRef('')
|
||||
setTagMessage('')
|
||||
loadData()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Failed to create tag', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTag = (tagName: string) => {
|
||||
openConfirmModal({
|
||||
title: 'Delete Tag',
|
||||
message: `Delete tag "${tagName}"?`,
|
||||
confirmText: 'Delete Tag',
|
||||
isDangerous: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.tag.delete(repoPath, tagName)
|
||||
if (res.success) {
|
||||
showToast({ type: 'info', title: `Deleted tag "${tagName}"` })
|
||||
loadData()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Delete tag failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handlePushTag = async (tagName: string) => {
|
||||
const remote = remotes.length > 0 ? remotes[0].name : 'origin'
|
||||
try {
|
||||
const res = await window.flowgit.tag.push(repoPath, remote, tagName)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Pushed tag "${tagName}" to ${remote}` })
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Push tag failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const localBranches = branches.filter((b) => !b.isRemote)
|
||||
const remoteBranches = branches.filter((b) => b.isRemote)
|
||||
|
||||
const filterText = search.toLowerCase()
|
||||
const filteredLocal = localBranches.filter((b) => b.name.toLowerCase().includes(filterText))
|
||||
const filteredRemote = remoteBranches.filter((b) => b.name.toLowerCase().includes(filterText))
|
||||
const filteredTags = tags.filter((t) => t.name.toLowerCase().includes(filterText))
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#000000',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
>
|
||||
{/* Top Header Bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: '#141414',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: '#eeeeee', letterSpacing: '0.04em' }}>
|
||||
BRANCHES & TAGS
|
||||
</span>
|
||||
<Input
|
||||
placeholder="Filter branches & tags…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ fontSize: '12px', padding: '4px 10px', width: '220px' }}
|
||||
/>
|
||||
{loading && <Spinner size="sm" />}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setNewTagModal(true)}>
|
||||
+ New Tag
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => setNewBranchModal(true)} showArrow>
|
||||
+ New Branch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area: 3 Columns / Sections */}
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{/* Local Branches (40%) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 2,
|
||||
borderRight: '1px solid #202020',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={colHeaderStyle}>LOCAL BRANCHES ({filteredLocal.length})</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{filteredLocal.map((b) => {
|
||||
const isCurrent = b.isCurrent
|
||||
return (
|
||||
<div
|
||||
key={b.name}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
borderLeft: isCurrent ? '2px solid #da5c2c' : '2px solid transparent',
|
||||
background: isCurrent ? '#191919' : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
style={{
|
||||
color: isCurrent ? '#da5c2c' : '#eeeeee',
|
||||
fontWeight: isCurrent ? 700 : 400,
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
{b.name}
|
||||
</span>
|
||||
{isCurrent && <Tag label="CURRENT" variant="head" size="sm" />}
|
||||
{(b.ahead > 0 || b.behind > 0) && (
|
||||
<span style={{ fontSize: '11px', color: '#b4b4b4' }}>
|
||||
{b.ahead > 0 && <span style={{ color: '#4caf50' }}>↑{b.ahead} </span>}
|
||||
{b.behind > 0 && <span style={{ color: '#ef5350' }}>↓{b.behind}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: '#606060', fontSize: '11px', marginTop: '2px' }}>
|
||||
{b.headMessage || b.headCommit?.substring(0, 7)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={() => handleSwitchBranch(b)}
|
||||
style={branchActionBtn}
|
||||
title="Checkout branch"
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
)}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={() => handleMergeBranch(b)}
|
||||
style={branchActionBtn}
|
||||
title="Merge into current"
|
||||
>
|
||||
Merge
|
||||
</button>
|
||||
)}
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={() => handleRebaseBranch(b)}
|
||||
style={branchActionBtn}
|
||||
title="Rebase onto this"
|
||||
>
|
||||
Rebase
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenameBranchModal(b)
|
||||
setRenamedName(b.name)
|
||||
}}
|
||||
style={branchActionBtn}
|
||||
title="Rename branch"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={() => handleDeleteBranch(b)}
|
||||
style={{ ...branchActionBtn, color: '#ef5350' }}
|
||||
title="Delete branch"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote Branches (30%) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1.5,
|
||||
borderRight: '1px solid #202020',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={colHeaderStyle}>REMOTE BRANCHES ({filteredRemote.length})</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{filteredRemote.map((b) => (
|
||||
<div
|
||||
key={b.fullName || b.name}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ color: '#b4b4b4', fontSize: '12px', fontWeight: 400 }}>
|
||||
{b.name}
|
||||
</div>
|
||||
<div style={{ color: '#505050', fontSize: '10px', marginTop: '2px' }}>
|
||||
{b.headCommit?.substring(0, 7)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const localName = b.name.replace(/^origin\//, '').replace(/^remotes\/[^/]+\//, '')
|
||||
setNewBranchModal(true)
|
||||
setNewBranchName(localName)
|
||||
setFromRef(b.name)
|
||||
}}
|
||||
style={branchActionBtn}
|
||||
title="Checkout as local branch"
|
||||
>
|
||||
Checkout
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags Section (30%) */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1.5,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={colHeaderStyle}>TAGS ({filteredTags.length})</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{filteredTags.length === 0 && (
|
||||
<div style={{ padding: '24px', textAlign: 'center', color: '#606060', fontSize: '12px' }}>
|
||||
No tags created yet.
|
||||
</div>
|
||||
)}
|
||||
{filteredTags.map((t) => (
|
||||
<div
|
||||
key={t.name}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Tag label={t.name} variant="tag" size="sm" />
|
||||
<span style={{ color: '#606060', fontSize: '11px' }}>{t.hash?.substring(0, 7)}</span>
|
||||
</div>
|
||||
{t.message && (
|
||||
<div style={{ color: '#7e7e7e', fontSize: '11px', marginTop: '2px' }}>
|
||||
{t.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
|
||||
<button onClick={() => handlePushTag(t.name)} style={branchActionBtn} title="Push tag">
|
||||
Push
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTag(t.name)}
|
||||
style={{ ...branchActionBtn, color: '#ef5350' }}
|
||||
title="Delete tag"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Branch Modal */}
|
||||
{newBranchModal && (
|
||||
<div style={modalBackdropStyle} onClick={() => setNewBranchModal(false)}>
|
||||
<div style={modalBoxStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={modalTitleStyle}>Create New Branch</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<Input
|
||||
label="BRANCH NAME"
|
||||
placeholder="feature/your-branch"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label="FROM REF (BRANCH OR COMMIT, LEAVE BLANK FOR HEAD)"
|
||||
placeholder="HEAD, main, or hash"
|
||||
value={fromRef}
|
||||
onChange={(e) => setFromRef(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '16px' }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setNewBranchModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleCreateBranch} showArrow>
|
||||
Create Branch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename Branch Modal */}
|
||||
{renameBranchModal && (
|
||||
<div style={modalBackdropStyle} onClick={() => setRenameBranchModal(null)}>
|
||||
<div style={modalBoxStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={modalTitleStyle}>Rename Branch "{renameBranchModal.name}"</div>
|
||||
<Input
|
||||
label="NEW BRANCH NAME"
|
||||
value={renamedName}
|
||||
onChange={(e) => setRenamedName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '16px' }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setRenameBranchModal(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleRenameBranch} showArrow>
|
||||
Rename
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Tag Modal */}
|
||||
{newTagModal && (
|
||||
<div style={modalBackdropStyle} onClick={() => setNewTagModal(false)}>
|
||||
<div style={modalBoxStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={modalTitleStyle}>Create New Tag</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<Input
|
||||
label="TAG NAME"
|
||||
placeholder="v1.0.0"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label="TARGET REF (COMMIT OR BRANCH, DEFAULT: HEAD)"
|
||||
placeholder="HEAD"
|
||||
value={tagRef}
|
||||
onChange={(e) => setTagRef(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="ANNOTATION MESSAGE (OPTIONAL)"
|
||||
placeholder="Release 1.0.0"
|
||||
value={tagMessage}
|
||||
onChange={(e) => setTagMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '16px' }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setNewTagModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleCreateTag} showArrow>
|
||||
Create Tag
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const colHeaderStyle: React.CSSProperties = {
|
||||
padding: '8px 16px',
|
||||
background: '#161616',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: '#7e7e7e',
|
||||
borderBottom: '1px solid #202020',
|
||||
letterSpacing: '0.04em',
|
||||
}
|
||||
|
||||
const branchActionBtn: React.CSSProperties = {
|
||||
background: '#191919',
|
||||
border: '1px solid #2a2a2a',
|
||||
borderRadius: '2px',
|
||||
color: '#b4b4b4',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '11px',
|
||||
padding: '3px 8px',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
const modalBackdropStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.75)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
}
|
||||
|
||||
const modalBoxStyle: React.CSSProperties = {
|
||||
background: '#191919',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
padding: '24px',
|
||||
width: '400px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}
|
||||
|
||||
const modalTitleStyle: React.CSSProperties = {
|
||||
color: '#eeeeee',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
marginBottom: '16px',
|
||||
}
|
||||
|
||||
export default BranchesView
|
||||
633
src/views/Repository/Changes/ChangesView.tsx
Normal file
633
src/views/Repository/Changes/ChangesView.tsx
Normal file
@ -0,0 +1,633 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useRepoStore } from '../../../store/repo.store'
|
||||
import { useUiStore } from '../../../store/ui.store'
|
||||
import Button from '../../../components/Button'
|
||||
import FileStatusIcon from '../../../components/FileStatusIcon'
|
||||
import DiffViewer from '../../../components/DiffViewer'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import type { ChangedFile, FileDiff, RepoStatus } from '@shared/types/git.types'
|
||||
|
||||
export interface ChangesViewProps {
|
||||
repoPath: string
|
||||
}
|
||||
|
||||
export const ChangesView: React.FC<ChangesViewProps> = ({ repoPath }) => {
|
||||
const { status, setStatus } = useRepoStore()
|
||||
const { showToast, openConfirmModal } = useUiStore()
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<{ path: string; staged: boolean } | null>(null)
|
||||
const [diff, setDiff] = useState<FileDiff | null>(null)
|
||||
const [loadingDiff, setLoadingDiff] = useState(false)
|
||||
const [loadingStatus, setLoadingStatus] = useState(false)
|
||||
|
||||
// Commit panel state
|
||||
const [commitMessage, setCommitMessage] = useState('')
|
||||
const [isAmend, setIsAmend] = useState(false)
|
||||
const [committing, setCommitting] = useState(false)
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!repoPath) return
|
||||
setLoadingStatus(true)
|
||||
try {
|
||||
const res = await window.flowgit.repo.getStatus(repoPath)
|
||||
if (res.success && res.data) {
|
||||
const repoStatus = res.data as RepoStatus
|
||||
setStatus(repoStatus)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Failed to refresh status',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoadingStatus(false)
|
||||
}
|
||||
}, [repoPath, setStatus, showToast])
|
||||
|
||||
// Load status on mount or when repoPath changes
|
||||
useEffect(() => {
|
||||
refreshStatus()
|
||||
}, [refreshStatus])
|
||||
|
||||
// Load diff when selected file changes
|
||||
const loadDiff = useCallback(
|
||||
async (file: { path: string; staged: boolean }) => {
|
||||
setLoadingDiff(true)
|
||||
try {
|
||||
const res = await window.flowgit.diff.file(repoPath, file.path, {
|
||||
staged: file.staged,
|
||||
})
|
||||
if (res.success && res.data) {
|
||||
setDiff(res.data as FileDiff)
|
||||
} else {
|
||||
setDiff(null)
|
||||
if (res.error) {
|
||||
showToast({ type: 'error', title: 'Cannot load diff', message: res.error })
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setDiff(null)
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error loading diff',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoadingDiff(false)
|
||||
}
|
||||
},
|
||||
[repoPath, showToast]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFile) {
|
||||
loadDiff(selectedFile)
|
||||
} else {
|
||||
setDiff(null)
|
||||
}
|
||||
}, [selectedFile, loadDiff])
|
||||
|
||||
// File action handlers
|
||||
const handleStageFile = async (filePath: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
try {
|
||||
const res = await window.flowgit.changes.stage(repoPath, filePath)
|
||||
if (res.success) {
|
||||
await refreshStatus()
|
||||
if (selectedFile?.path === filePath) {
|
||||
setSelectedFile({ path: filePath, staged: true })
|
||||
}
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Stage failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnstageFile = async (filePath: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
try {
|
||||
const res = await window.flowgit.changes.unstage(repoPath, filePath)
|
||||
if (res.success) {
|
||||
await refreshStatus()
|
||||
if (selectedFile?.path === filePath) {
|
||||
setSelectedFile({ path: filePath, staged: false })
|
||||
}
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Unstage failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleStageAll = async () => {
|
||||
try {
|
||||
const res = await window.flowgit.changes.stageAll(repoPath)
|
||||
if (res.success) {
|
||||
await refreshStatus()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Stage all failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnstageAll = async () => {
|
||||
try {
|
||||
const res = await window.flowgit.changes.unstageAll(repoPath)
|
||||
if (res.success) {
|
||||
await refreshStatus()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Unstage all failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscardFile = (filePath: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
openConfirmModal({
|
||||
title: 'Discard Changes',
|
||||
message: `Are you sure you want to discard all changes in "${filePath}"? This action cannot be undone.`,
|
||||
confirmText: 'Discard Changes',
|
||||
isDangerous: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.changes.discard(repoPath, filePath)
|
||||
if (res.success) {
|
||||
showToast({ type: 'info', title: 'Changes discarded', message: filePath })
|
||||
await refreshStatus()
|
||||
if (selectedFile?.path === filePath) {
|
||||
setSelectedFile(null)
|
||||
}
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Discard failed', message: res.error })
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleStageHunk = async (hunkIndex: number) => {
|
||||
if (!selectedFile) return
|
||||
try {
|
||||
const res = await window.flowgit.changes.stageHunk(repoPath, selectedFile.path, hunkIndex)
|
||||
if (res.success) {
|
||||
await refreshStatus()
|
||||
loadDiff(selectedFile)
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Stage hunk failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnstageHunk = async (hunkIndex: number) => {
|
||||
if (!selectedFile) return
|
||||
try {
|
||||
const res = await window.flowgit.changes.unstageHunk(repoPath, selectedFile.path, hunkIndex)
|
||||
if (res.success) {
|
||||
await refreshStatus()
|
||||
loadDiff(selectedFile)
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Unstage hunk failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!commitMessage.trim()) {
|
||||
showToast({ type: 'warning', title: 'Empty Commit Message', message: 'Please enter a commit message.' })
|
||||
return
|
||||
}
|
||||
|
||||
const stagedCount = status?.staged.length ?? 0
|
||||
if (stagedCount === 0 && !isAmend) {
|
||||
showToast({ type: 'warning', title: 'No Staged Changes', message: 'Stage changes before committing.' })
|
||||
return
|
||||
}
|
||||
|
||||
setCommitting(true)
|
||||
try {
|
||||
const res = await window.flowgit.commit.create(repoPath, commitMessage.trim(), { amend: isAmend })
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: isAmend ? 'Commit amended' : 'Committed successfully' })
|
||||
setCommitMessage('')
|
||||
setIsAmend(false)
|
||||
await refreshStatus()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Commit Failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error committing', message: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setCommitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stagedFiles = status?.staged ?? []
|
||||
const unstagedFiles = status?.unstaged ?? []
|
||||
const untrackedFiles = status?.untracked ?? []
|
||||
const conflictedFiles = status?.conflicted ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#000000',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
>
|
||||
{/* ── Left Panel (280px): File Lists ── */}
|
||||
<div
|
||||
style={{
|
||||
width: '280px',
|
||||
background: '#111111',
|
||||
borderRight: '1px solid #202020',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header with Refresh */}
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
background: '#141414',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '12px', fontWeight: 700, color: '#eeeeee', letterSpacing: '0.04em' }}>
|
||||
CHANGES
|
||||
</span>
|
||||
<button
|
||||
onClick={refreshStatus}
|
||||
disabled={loadingStatus}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#7e7e7e',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
title="Refresh Status"
|
||||
>
|
||||
{loadingStatus ? 'Refreshing…' : '↻'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable File Sections */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{/* Conflicted Section */}
|
||||
{conflictedFiles.length > 0 && (
|
||||
<div style={{ borderBottom: '1px solid #202020' }}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={{ color: '#da5c2c', fontWeight: 700 }}>
|
||||
CONFLICTS ({conflictedFiles.length})
|
||||
</span>
|
||||
</div>
|
||||
{conflictedFiles.map((file) => (
|
||||
<FileRow
|
||||
key={`conflicted-${file.path}`}
|
||||
file={file}
|
||||
isSelected={selectedFile?.path === file.path}
|
||||
onClick={() => setSelectedFile({ path: file.path, staged: false })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Staged Section */}
|
||||
<div style={{ borderBottom: '1px solid #202020' }}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={{ color: '#eeeeee' }}>
|
||||
STAGED ({stagedFiles.length})
|
||||
</span>
|
||||
{stagedFiles.length > 0 && (
|
||||
<button onClick={handleUnstageAll} style={sectionActionBtnStyle}>
|
||||
Unstage All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{stagedFiles.map((file) => (
|
||||
<FileRow
|
||||
key={`staged-${file.path}`}
|
||||
file={file}
|
||||
isSelected={selectedFile?.path === file.path && selectedFile.staged}
|
||||
onClick={() => setSelectedFile({ path: file.path, staged: true })}
|
||||
onAction={(e) => handleUnstageFile(file.path, e)}
|
||||
actionLabel="-"
|
||||
actionTitle="Unstage file"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Unstaged Section */}
|
||||
<div style={{ borderBottom: '1px solid #202020' }}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={{ color: '#b4b4b4' }}>
|
||||
UNSTAGED ({unstagedFiles.length})
|
||||
</span>
|
||||
{unstagedFiles.length > 0 && (
|
||||
<button onClick={handleStageAll} style={sectionActionBtnStyle}>
|
||||
Stage All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{unstagedFiles.map((file) => (
|
||||
<FileRow
|
||||
key={`unstaged-${file.path}`}
|
||||
file={file}
|
||||
isSelected={selectedFile?.path === file.path && !selectedFile.staged}
|
||||
onClick={() => setSelectedFile({ path: file.path, staged: false })}
|
||||
onAction={(e) => handleStageFile(file.path, e)}
|
||||
actionLabel="+"
|
||||
actionTitle="Stage file"
|
||||
onDiscard={(e) => handleDiscardFile(file.path, e)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Untracked Section */}
|
||||
<div>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={{ color: '#7e7e7e' }}>
|
||||
UNTRACKED ({untrackedFiles.length})
|
||||
</span>
|
||||
</div>
|
||||
{untrackedFiles.map((file) => (
|
||||
<FileRow
|
||||
key={`untracked-${file.path}`}
|
||||
file={file}
|
||||
isSelected={selectedFile?.path === file.path}
|
||||
onClick={() => setSelectedFile({ path: file.path, staged: false })}
|
||||
onAction={(e) => handleStageFile(file.path, e)}
|
||||
actionLabel="+"
|
||||
actionTitle="Track & stage file"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Center Panel: Diff Viewer ── */}
|
||||
<div style={{ flex: 1, height: '100%', overflow: 'hidden' }}>
|
||||
<DiffViewer
|
||||
diff={diff}
|
||||
filePath={selectedFile?.path}
|
||||
isStaged={selectedFile?.staged}
|
||||
onStageHunk={handleStageHunk}
|
||||
onUnstageHunk={handleUnstageHunk}
|
||||
isLoading={loadingDiff}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Right Panel (260px): Commit Panel ── */}
|
||||
<div
|
||||
style={{
|
||||
width: '260px',
|
||||
background: '#111111',
|
||||
borderLeft: '1px solid #202020',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
padding: '14px',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
color: '#eeeeee',
|
||||
letterSpacing: '0.04em',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
COMMIT
|
||||
</div>
|
||||
|
||||
{/* Commit Message Textarea */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', flex: 1, minHeight: '140px' }}>
|
||||
<textarea
|
||||
placeholder="Commit message (monospace)…"
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
background: '#202020',
|
||||
border: '1px solid #3a3a3a',
|
||||
borderRadius: '2px',
|
||||
color: '#eeeeee',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '12px',
|
||||
padding: '10px',
|
||||
resize: 'none',
|
||||
outline: 'none',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '11px',
|
||||
color: '#606060',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
<span>Characters: {commitMessage.length}</span>
|
||||
{commitMessage.length > 50 && (
|
||||
<span style={{ color: '#e5c07b' }}>>50 summary</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options & Warnings */}
|
||||
<div style={{ marginTop: '14px', display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#b4b4b4',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAmend}
|
||||
onChange={(e) => setIsAmend(e.target.checked)}
|
||||
style={{ accentColor: '#da5c2c' }}
|
||||
/>
|
||||
<span>Amend previous commit</span>
|
||||
</label>
|
||||
|
||||
{stagedFiles.length === 0 && !isAmend && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: '#e5c07b',
|
||||
background: '#1c1b14',
|
||||
border: '1px solid #3d351e',
|
||||
borderRadius: '2px',
|
||||
padding: '6px 8px',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
⚠ No staged changes. Stage files to commit.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit Button */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleCommit}
|
||||
disabled={committing || (!isAmend && stagedFiles.length === 0) || !commitMessage.trim()}
|
||||
loading={committing}
|
||||
showArrow
|
||||
style={{ width: '100%', marginTop: '4px' }}
|
||||
>
|
||||
{isAmend ? 'Amend Commit' : `Commit (${stagedFiles.length})`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileRow({
|
||||
file,
|
||||
isSelected,
|
||||
onClick,
|
||||
onAction,
|
||||
actionLabel,
|
||||
actionTitle,
|
||||
onDiscard,
|
||||
}: {
|
||||
file: ChangedFile
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
onAction?: (e: React.MouseEvent) => void
|
||||
actionLabel?: string
|
||||
actionTitle?: string
|
||||
onDiscard?: (e: React.MouseEvent) => void
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '6px 12px',
|
||||
background: isSelected ? '#191919' : hovered ? '#141414' : 'transparent',
|
||||
borderLeft: isSelected ? '2px solid #da5c2c' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
transition: 'background-color 0.08s ease',
|
||||
}}
|
||||
>
|
||||
<FileStatusIcon status={file.status} />
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
color: isSelected ? '#eeeeee' : '#b4b4b4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={file.path}
|
||||
>
|
||||
{file.path}
|
||||
</span>
|
||||
|
||||
{/* Hover Action Buttons */}
|
||||
{hovered && (
|
||||
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
||||
{onDiscard && (
|
||||
<button
|
||||
onClick={onDiscard}
|
||||
style={iconBtnStyle}
|
||||
title="Discard changes"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{onAction && actionLabel && (
|
||||
<button
|
||||
onClick={onAction}
|
||||
style={iconBtnStyle}
|
||||
title={actionTitle}
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionHeaderStyle: React.CSSProperties = {
|
||||
padding: '6px 12px',
|
||||
background: '#161616',
|
||||
fontSize: '11px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
letterSpacing: '0.04em',
|
||||
}
|
||||
|
||||
const sectionActionBtnStyle: React.CSSProperties = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#7e7e7e',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}
|
||||
|
||||
const iconBtnStyle: React.CSSProperties = {
|
||||
background: '#202020',
|
||||
border: '1px solid #3a3a3a',
|
||||
borderRadius: '2px',
|
||||
color: '#eeeeee',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
}
|
||||
|
||||
export default ChangesView
|
||||
217
src/views/Repository/FileBrowser/FileBrowserView.tsx
Normal file
217
src/views/Repository/FileBrowser/FileBrowserView.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useUiStore } from '../../../store/ui.store'
|
||||
import Button from '../../../components/Button'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import type { BlameEntry } from '@shared/types/git.types'
|
||||
|
||||
export interface FileBrowserViewProps {
|
||||
repoPath: string
|
||||
}
|
||||
|
||||
export const FileBrowserView: React.FC<FileBrowserViewProps> = ({ repoPath }) => {
|
||||
const { showToast } = useUiStore()
|
||||
const [blameFilePath, setBlameFilePath] = useState('')
|
||||
const [blameEntries, setBlameEntries] = useState<BlameEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleOpenExplorer = async () => {
|
||||
try {
|
||||
await window.flowgit.openPath(repoPath)
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Cannot open folder', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunBlame = async () => {
|
||||
if (!blameFilePath.trim()) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await window.flowgit.blame.file(repoPath, blameFilePath.trim())
|
||||
if (res.success && res.data) {
|
||||
setBlameEntries(res.data as BlameEntry[])
|
||||
} else {
|
||||
setBlameEntries([])
|
||||
showToast({ type: 'error', title: 'Blame failed', message: res.error || 'File not found or has no history' })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#000000',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
>
|
||||
{/* Top Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: '#141414',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: '#eeeeee', letterSpacing: '0.04em' }}>
|
||||
FILE EXPLORER & GIT BLAME
|
||||
</span>
|
||||
<span style={{ fontSize: '12px', color: '#606060' }}>{repoPath}</span>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenExplorer}>
|
||||
Open in System Explorer ↗
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Blame Search Strip */}
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#161616',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '12px', color: '#b4b4b4' }}>Inspect File Blame:</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. src/index.ts, package.json…"
|
||||
value={blameFilePath}
|
||||
onChange={(e) => setBlameFilePath(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRunBlame()}
|
||||
style={{
|
||||
background: '#111111',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
color: '#eeeeee',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '12px',
|
||||
padding: '5px 10px',
|
||||
width: '280px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
<Button variant="secondary" size="sm" onClick={handleRunBlame} loading={loading}>
|
||||
Blame
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Spinner size="md" label="Analyzing file history and blame…" />
|
||||
</div>
|
||||
) : blameEntries.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
background: '#0d0d0d',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
{blameEntries.map((b, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderBottom: '1px solid #141414',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '70px',
|
||||
padding: '2px 8px',
|
||||
color: '#da5c2c',
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{b.shortHash}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '120px',
|
||||
padding: '2px 8px',
|
||||
color: '#7e7e7e',
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{b.author}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
padding: '2px 8px',
|
||||
color: '#505050',
|
||||
textAlign: 'right',
|
||||
borderRight: '1px solid #1a1a1a',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{b.lineNumber}
|
||||
</div>
|
||||
<pre
|
||||
style={{
|
||||
flex: 1,
|
||||
margin: 0,
|
||||
padding: '2px 8px',
|
||||
color: '#eeeeee',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
whiteSpace: 'pre',
|
||||
}}
|
||||
>
|
||||
{b.lineContent}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#606060',
|
||||
fontSize: '13px',
|
||||
gap: '10px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '28px', opacity: 0.5 }}>▤</span>
|
||||
<span>Enter a relative file path above to view git blame history</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenExplorer}>
|
||||
Open repository folder in File Explorer →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileBrowserView
|
||||
781
src/views/Repository/Graph/GraphView.tsx
Normal file
781
src/views/Repository/Graph/GraphView.tsx
Normal file
@ -0,0 +1,781 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { useUiStore } from '../../../store/ui.store'
|
||||
import Button from '../../../components/Button'
|
||||
import Input from '../../../components/Input'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import Tag from '../../../components/Tag'
|
||||
import FileStatusIcon from '../../../components/FileStatusIcon'
|
||||
import type { GraphData, GraphNode, GraphEdge, CommitDetail, CommitFile } from '@shared/types/git.types'
|
||||
|
||||
export interface GraphViewProps {
|
||||
repoPath: string
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 240
|
||||
const NODE_HEIGHT = 80
|
||||
const COL_WIDTH = 280
|
||||
const ROW_HEIGHT = 120
|
||||
const PADDING = 80
|
||||
|
||||
export const GraphView: React.FC<GraphViewProps> = ({ repoPath }) => {
|
||||
const { showToast, openConfirmModal } = useUiStore()
|
||||
|
||||
const [graphData, setGraphData] = useState<GraphData | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedHash, setSelectedHash] = useState<string | null>(null)
|
||||
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null)
|
||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Pan and Zoom
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [pan, setPan] = useState({ x: 50, y: 50 })
|
||||
const isDraggingRef = useRef(false)
|
||||
const dragStartRef = useRef({ x: 0, y: 0 })
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load graph data
|
||||
const loadGraph = useCallback(async () => {
|
||||
if (!repoPath) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await window.flowgit.history.graph(repoPath, { limit: 300 })
|
||||
if (res.success && res.data) {
|
||||
const data = res.data as GraphData
|
||||
setGraphData(data)
|
||||
// Select HEAD if present
|
||||
const headNode = data.nodes.find((n) => n.isHead)
|
||||
if (headNode) {
|
||||
setSelectedHash(headNode.hash)
|
||||
} else if (data.nodes.length > 0) {
|
||||
setSelectedHash(data.nodes[0].hash)
|
||||
}
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Cannot load graph', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error loading graph',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [repoPath, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
loadGraph()
|
||||
}, [loadGraph])
|
||||
|
||||
// Fetch commit details when selectedHash changes
|
||||
useEffect(() => {
|
||||
if (!selectedHash || !repoPath) {
|
||||
setCommitDetail(null)
|
||||
return
|
||||
}
|
||||
|
||||
let isMounted = true
|
||||
setLoadingDetail(true)
|
||||
window.flowgit.history
|
||||
.getCommit(repoPath, selectedHash)
|
||||
.then((res) => {
|
||||
if (!isMounted) return
|
||||
if (res.success && res.data) {
|
||||
setCommitDetail(res.data as CommitDetail)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!isMounted) return
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error loading commit details',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) setLoadingDetail(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [selectedHash, repoPath, showToast])
|
||||
|
||||
// Node position map
|
||||
const nodeMap = useMemo(() => {
|
||||
const map = new Map<string, GraphNode & { x: number; y: number }>()
|
||||
if (!graphData) return map
|
||||
for (const node of graphData.nodes) {
|
||||
const x = PADDING + (node.column ?? 0) * COL_WIDTH
|
||||
const y = PADDING + (node.row ?? 0) * ROW_HEIGHT
|
||||
map.set(node.hash, { ...node, x, y })
|
||||
}
|
||||
return map
|
||||
}, [graphData])
|
||||
|
||||
// Filtered nodes by search query
|
||||
const matchingNodeHashes = useMemo(() => {
|
||||
if (!searchQuery.trim() || !graphData) return null
|
||||
const query = searchQuery.toLowerCase()
|
||||
const matches = new Set<string>()
|
||||
for (const node of graphData.nodes) {
|
||||
if (
|
||||
node.message.toLowerCase().includes(query) ||
|
||||
node.author.toLowerCase().includes(query) ||
|
||||
node.shortHash.toLowerCase().includes(query) ||
|
||||
node.refs.some((r) => r.toLowerCase().includes(query))
|
||||
) {
|
||||
matches.add(node.hash)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}, [graphData, searchQuery])
|
||||
|
||||
// Pan by dragging
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return // Left click only
|
||||
// If clicked on canvas background
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'svg' || target.getAttribute('data-canvas-bg') === 'true') {
|
||||
isDraggingRef.current = true
|
||||
dragStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y }
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDraggingRef.current) return
|
||||
setPan({
|
||||
x: e.clientX - dragStartRef.current.x,
|
||||
y: e.clientY - dragStartRef.current.y,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isDraggingRef.current = false
|
||||
}
|
||||
|
||||
// Zoom with wheel (0.25x to 3x)
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault()
|
||||
const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9
|
||||
const newZoom = Math.min(3, Math.max(0.25, zoom * zoomFactor))
|
||||
|
||||
// Zoom toward cursor point
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const mouseX = e.clientX - rect.left
|
||||
const mouseY = e.clientY - rect.top
|
||||
const newPanX = mouseX - (mouseX - pan.x) * (newZoom / zoom)
|
||||
const newPanY = mouseY - (mouseY - pan.y) * (newZoom / zoom)
|
||||
setZoom(newZoom)
|
||||
setPan({ x: newPanX, y: newPanY })
|
||||
} else {
|
||||
setZoom(newZoom)
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to HEAD
|
||||
const handleJumpToHead = () => {
|
||||
if (!graphData) return
|
||||
const head = graphData.nodes.find((n) => n.isHead) ?? graphData.nodes[0]
|
||||
if (!head) return
|
||||
zoomToNode(head.hash)
|
||||
}
|
||||
|
||||
// Fit to view
|
||||
const handleFitToView = () => {
|
||||
if (!graphData || !containerRef.current || graphData.nodes.length === 0) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const totalCols = Math.max(1, graphData.columns || 1)
|
||||
const totalRows = Math.max(1, Math.min(graphData.totalRows || 1, 20)) // fit initial window
|
||||
const contentW = PADDING * 2 + totalCols * COL_WIDTH
|
||||
const contentH = PADDING * 2 + totalRows * ROW_HEIGHT
|
||||
|
||||
const scaleX = rect.width / contentW
|
||||
const scaleY = rect.height / contentH
|
||||
const newScale = Math.min(1.2, Math.max(0.35, Math.min(scaleX, scaleY)))
|
||||
|
||||
setZoom(newScale)
|
||||
setPan({ x: 40, y: 40 })
|
||||
}
|
||||
|
||||
// Zoom to specific node
|
||||
const zoomToNode = (hash: string) => {
|
||||
const node = nodeMap.get(hash)
|
||||
if (!node || !containerRef.current) return
|
||||
setSelectedHash(hash)
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const targetX = rect.width / 2 - (node.x + NODE_WIDTH / 2) * zoom
|
||||
const targetY = rect.height / 2 - (node.y + NODE_HEIGHT / 2) * zoom
|
||||
setPan({ x: targetX, y: targetY })
|
||||
}
|
||||
|
||||
// Action handlers
|
||||
const handleCherryPick = async () => {
|
||||
if (!selectedHash) return
|
||||
openConfirmModal({
|
||||
title: 'Cherry-pick Node',
|
||||
message: `Apply commit ${selectedHash.substring(0, 7)} onto current HEAD?`,
|
||||
confirmText: 'Cherry-pick',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.commit.cherryPick(repoPath, selectedHash)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: 'Cherry-pick complete' })
|
||||
loadGraph()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Cherry-pick failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (!selectedHash) return
|
||||
openConfirmModal({
|
||||
title: 'Checkout Commit',
|
||||
message: `Checkout commit ${selectedHash.substring(0, 7)}? This will detach HEAD.`,
|
||||
confirmText: 'Checkout',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.branch.switch(repoPath, selectedHash)
|
||||
if (res.success) {
|
||||
showToast({ type: 'info', title: 'Switched to commit', message: selectedHash.substring(0, 7) })
|
||||
loadGraph()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Checkout failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Minimap calculations
|
||||
const minimapNodes = useMemo(() => {
|
||||
if (!graphData || graphData.nodes.length === 0) return []
|
||||
const totalW = Math.max(1, (graphData.columns + 1) * COL_WIDTH)
|
||||
const totalH = Math.max(1, (graphData.totalRows + 1) * ROW_HEIGHT)
|
||||
const scaleX = 100 / totalW
|
||||
const scaleY = 80 / totalH
|
||||
|
||||
return graphData.nodes.map((n) => {
|
||||
const pos = nodeMap.get(n.hash)
|
||||
return {
|
||||
hash: n.hash,
|
||||
x: (pos?.x ?? 0) * scaleX,
|
||||
y: (pos?.y ?? 0) * scaleY,
|
||||
color: n.color || '#da5c2c',
|
||||
isHead: n.isHead,
|
||||
isSelected: n.hash === selectedHash,
|
||||
}
|
||||
})
|
||||
}, [graphData, nodeMap, selectedHash])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#000000',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* ── Main Canvas Workspace ── */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
cursor: isDraggingRef.current ? 'grabbing' : 'grab',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* Top Control Bar */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
left: '16px',
|
||||
zIndex: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
background: 'rgba(17, 17, 17, 0.85)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
padding: '6px 10px',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Search commits…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{ fontSize: '11px', padding: '4px 8px', width: '160px' }}
|
||||
/>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleJumpToHead} title="Jump to HEAD">
|
||||
HEAD
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleFitToView} title="Fit to screen">
|
||||
Fit
|
||||
</Button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
borderLeft: '1px solid #202020',
|
||||
paddingLeft: '6px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.max(0.25, z - 0.15))}
|
||||
style={toolIconBtn}
|
||||
title="Zoom Out"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span style={{ fontSize: '11px', color: '#b4b4b4', minWidth: '36px', textAlign: 'center' }}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.min(3, z + 0.15))}
|
||||
style={toolIconBtn}
|
||||
title="Zoom In"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <Spinner size="sm" />}
|
||||
</div>
|
||||
|
||||
{/* SVG Canvas */}
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ display: 'block', background: '#000000' }}
|
||||
>
|
||||
<defs>
|
||||
{/* Dot Grid Pattern (dots at 20px intervals in #191919) */}
|
||||
<pattern
|
||||
id="blueprintGrid"
|
||||
width={20 * zoom}
|
||||
height={20 * zoom}
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternTransform={`translate(${pan.x}, ${pan.y})`}
|
||||
>
|
||||
<circle cx={10 * zoom} cy={10 * zoom} r={1} fill="#191919" />
|
||||
</pattern>
|
||||
|
||||
{/* Arrowhead marker */}
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
refX="5"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 6 3, 0 6" fill="#da5c2c" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Background dot grid rect */}
|
||||
<rect
|
||||
data-canvas-bg="true"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="url(#blueprintGrid)"
|
||||
/>
|
||||
|
||||
{/* Scaled/Panned Graph Container */}
|
||||
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
||||
{/* ── Edges / Connections ── */}
|
||||
{graphData?.edges.map((edge: GraphEdge, idx: number) => {
|
||||
const src = nodeMap.get(edge.from)
|
||||
const dst = nodeMap.get(edge.to)
|
||||
if (!src || !dst) return null
|
||||
|
||||
// Coordinates from bottom-center of src to top-center of dst
|
||||
const x1 = src.x + NODE_WIDTH / 2
|
||||
const y1 = src.y + NODE_HEIGHT
|
||||
const x2 = dst.x + NODE_WIDTH / 2
|
||||
const y2 = dst.y
|
||||
|
||||
// Bezier curve
|
||||
const dy = y2 - y1
|
||||
const path = `M ${x1} ${y1} C ${x1} ${y1 + dy * 0.5}, ${x2} ${y1 + dy * 0.5}, ${x2} ${y2}`
|
||||
|
||||
const strokeColor = edge.color || '#3a3a3a'
|
||||
|
||||
return (
|
||||
<path
|
||||
key={`edge-${idx}`}
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth={2}
|
||||
strokeOpacity={0.8}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Nodes ── */}
|
||||
{graphData?.nodes.map((node: GraphNode) => {
|
||||
const pos = nodeMap.get(node.hash)
|
||||
if (!pos) return null
|
||||
|
||||
const isSelected = selectedHash === node.hash
|
||||
const isMatch = matchingNodeHashes ? matchingNodeHashes.has(node.hash) : true
|
||||
|
||||
return (
|
||||
<g
|
||||
key={node.hash}
|
||||
transform={`translate(${pos.x}, ${pos.y})`}
|
||||
onClick={() => setSelectedHash(node.hash)}
|
||||
onDoubleClick={() => zoomToNode(node.hash)}
|
||||
style={{ cursor: 'pointer', opacity: isMatch ? 1 : 0.3 }}
|
||||
>
|
||||
{/* Node Background Box */}
|
||||
<rect
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
rx={2}
|
||||
ry={2}
|
||||
fill={isSelected ? '#191919' : '#111111'}
|
||||
stroke={isSelected ? '#da5c2c' : '#202020'}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
|
||||
{/* Left accent border for selected node */}
|
||||
{isSelected && (
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={2}
|
||||
height={NODE_HEIGHT}
|
||||
fill="#da5c2c"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Top Header Strip */}
|
||||
<rect
|
||||
x={isSelected ? 2 : 0}
|
||||
y={0}
|
||||
width={isSelected ? NODE_WIDTH - 2 : NODE_WIDTH}
|
||||
height={24}
|
||||
fill="#161616"
|
||||
/>
|
||||
|
||||
{/* Branch Color Accent Bar */}
|
||||
<rect
|
||||
x={isSelected ? 2 : 0}
|
||||
y={0}
|
||||
width={3}
|
||||
height={24}
|
||||
fill={node.color || '#da5c2c'}
|
||||
/>
|
||||
|
||||
{/* Short Hash */}
|
||||
<text
|
||||
x={10}
|
||||
y={16}
|
||||
fill="#da5c2c"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{node.shortHash}
|
||||
</text>
|
||||
|
||||
{/* HEAD Badge */}
|
||||
{node.isHead && (
|
||||
<g transform="translate(70, 4)">
|
||||
<rect
|
||||
width={36}
|
||||
height={16}
|
||||
rx={1}
|
||||
fill="#da5c2c"
|
||||
/>
|
||||
<text
|
||||
x={18}
|
||||
y={12}
|
||||
fill="#ffffff"
|
||||
fontSize={9}
|
||||
fontWeight={700}
|
||||
textAnchor="middle"
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
HEAD
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Refs / Branch Tags on Right */}
|
||||
{node.refs && node.refs.length > 0 && (
|
||||
<g transform={`translate(${NODE_WIDTH - 8}, 16)`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
fill="#61afef"
|
||||
fontSize={10}
|
||||
textAnchor="end"
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{node.refs[0]}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Commit Message */}
|
||||
<text
|
||||
x={10}
|
||||
y={44}
|
||||
fill="#eeeeee"
|
||||
fontSize={12}
|
||||
fontWeight={400}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{node.message.length > 28
|
||||
? `${node.message.substring(0, 26)}…`
|
||||
: node.message}
|
||||
</text>
|
||||
|
||||
{/* Author + Date */}
|
||||
<text
|
||||
x={10}
|
||||
y={66}
|
||||
fill="#b4b4b4"
|
||||
fontSize={10}
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{node.author}
|
||||
</text>
|
||||
|
||||
<text
|
||||
x={NODE_WIDTH - 10}
|
||||
y={66}
|
||||
fill="#606060"
|
||||
fontSize={10}
|
||||
textAnchor="end"
|
||||
fontFamily="var(--font-mono, monospace)"
|
||||
>
|
||||
{node.authorDate}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* ── Minimap (Bottom-Right, 100x80px) ── */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '16px',
|
||||
right: selectedHash ? '336px' : '16px', // adjust when detail panel is open
|
||||
width: '100px',
|
||||
height: '80px',
|
||||
background: '#111111',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
zIndex: 30,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<svg width="100" height="80">
|
||||
{minimapNodes.map((mn) => (
|
||||
<rect
|
||||
key={`mini-${mn.hash}`}
|
||||
x={Math.max(2, Math.min(94, mn.x))}
|
||||
y={Math.max(2, Math.min(74, mn.y))}
|
||||
width={mn.isSelected ? 5 : 3}
|
||||
height={mn.isSelected ? 5 : 3}
|
||||
fill={mn.isSelected ? '#ffffff' : mn.isHead ? '#da5c2c' : mn.color}
|
||||
opacity={mn.isSelected ? 1 : 0.7}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right Panel: Commit Details ── */}
|
||||
{selectedHash && (
|
||||
<div
|
||||
style={{
|
||||
width: '320px',
|
||||
background: '#111111',
|
||||
borderLeft: '1px solid #202020',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '14px',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '12px', fontWeight: 700, color: '#eeeeee', letterSpacing: '0.04em' }}>
|
||||
COMMIT DETAIL
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSelectedHash(null)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#606060',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
padding: '2px 6px',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingDetail ? (
|
||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
||||
<Spinner size="sm" label="Loading…" />
|
||||
</div>
|
||||
) : commitDetail ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<div style={{ color: '#eeeeee', fontSize: '14px', fontWeight: 700, lineHeight: 1.4 }}>
|
||||
{commitDetail.message}
|
||||
</div>
|
||||
{commitDetail.body && (
|
||||
<div
|
||||
style={{
|
||||
color: '#b4b4b4',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.5,
|
||||
marginTop: '6px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{commitDetail.body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '11px' }}>
|
||||
<div style={{ color: '#7e7e7e' }}>
|
||||
Author: <span style={{ color: '#eeeeee' }}>{commitDetail.author}</span>
|
||||
</div>
|
||||
<div style={{ color: '#7e7e7e' }}>
|
||||
Date: <span style={{ color: '#b4b4b4' }}>{commitDetail.authorDate}</span>
|
||||
</div>
|
||||
<div style={{ color: '#7e7e7e' }}>
|
||||
SHA:{' '}
|
||||
<span
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(commitDetail.hash)
|
||||
showToast({ type: 'info', title: 'SHA copied' })
|
||||
}}
|
||||
style={{ color: '#da5c2c', cursor: 'pointer', textDecoration: 'underline' }}
|
||||
title="Click to copy"
|
||||
>
|
||||
{commitDetail.hash.substring(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '8px', paddingTop: '4px' }}>
|
||||
<Button variant="ghost" size="sm" onClick={handleCherryPick} style={{ flex: 1 }}>
|
||||
Cherry-pick
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleCheckout} style={{ flex: 1 }}>
|
||||
Checkout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Changed files */}
|
||||
<div style={{ borderTop: '1px solid #202020', paddingTop: '10px' }}>
|
||||
<div style={{ fontSize: '11px', color: '#7e7e7e', marginBottom: '8px' }}>
|
||||
FILES ({commitDetail.files?.length ?? 0})
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{commitDetail.files?.map((f: CommitFile) => (
|
||||
<div
|
||||
key={f.path}
|
||||
style={{
|
||||
padding: '4px 6px',
|
||||
background: '#161616',
|
||||
borderRadius: '2px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', overflow: 'hidden' }}>
|
||||
<FileStatusIcon status={f.status} />
|
||||
<span
|
||||
style={{
|
||||
color: '#b4b4b4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={f.path}
|
||||
>
|
||||
{f.path}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ display: 'inline-flex', gap: '4px', flexShrink: 0, fontSize: '10px' }}>
|
||||
{f.additions > 0 && <span style={{ color: '#4caf50' }}>+{f.additions}</span>}
|
||||
{f.deletions > 0 && <span style={{ color: '#ef5350' }}>-{f.deletions}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const toolIconBtn: React.CSSProperties = {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#eeeeee',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
padding: '0 4px',
|
||||
}
|
||||
|
||||
export default GraphView
|
||||
613
src/views/Repository/History/HistoryView.tsx
Normal file
613
src/views/Repository/History/HistoryView.tsx
Normal file
@ -0,0 +1,613 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useRepoStore } from '../../../store/repo.store'
|
||||
import { useUiStore } from '../../../store/ui.store'
|
||||
import Button from '../../../components/Button'
|
||||
import Input from '../../../components/Input'
|
||||
import Tag from '../../../components/Tag'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import DiffViewer from '../../../components/DiffViewer'
|
||||
import FileStatusIcon from '../../../components/FileStatusIcon'
|
||||
import type { Commit, CommitDetail, CommitFile, FileDiff } from '@shared/types/git.types'
|
||||
|
||||
export interface HistoryViewProps {
|
||||
repoPath: string
|
||||
}
|
||||
|
||||
export const HistoryView: React.FC<HistoryViewProps> = ({ repoPath }) => {
|
||||
const { showToast, openConfirmModal } = useUiStore()
|
||||
|
||||
const [commits, setCommits] = useState<Commit[]>([])
|
||||
const [selectedHash, setSelectedHash] = useState<string | null>(null)
|
||||
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [loadingLog, setLoadingLog] = useState(false)
|
||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||
|
||||
// Selected file diff inside commit
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||
const [fileDiff, setFileDiff] = useState<FileDiff | null>(null)
|
||||
const [loadingDiff, setLoadingDiff] = useState(false)
|
||||
|
||||
// Branch creation modal state
|
||||
const [newBranchModal, setNewBranchModal] = useState(false)
|
||||
const [newBranchName, setNewBranchName] = useState('')
|
||||
|
||||
const fetchLog = useCallback(async () => {
|
||||
if (!repoPath) return
|
||||
setLoadingLog(true)
|
||||
try {
|
||||
const res = await window.flowgit.history.log(repoPath, {
|
||||
limit: 200,
|
||||
search: searchQuery.trim() || undefined,
|
||||
})
|
||||
if (res.success && res.data) {
|
||||
const list = res.data as Commit[]
|
||||
setCommits(list)
|
||||
if (list.length > 0 && !selectedHash) {
|
||||
setSelectedHash(list[0].hash)
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Failed to fetch history',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoadingLog(false)
|
||||
}
|
||||
}, [repoPath, searchQuery, selectedHash, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchLog()
|
||||
}, [fetchLog])
|
||||
|
||||
// Fetch commit details when selectedHash changes
|
||||
useEffect(() => {
|
||||
if (!selectedHash || !repoPath) {
|
||||
setCommitDetail(null)
|
||||
return
|
||||
}
|
||||
|
||||
let isMounted = true
|
||||
setLoadingDetail(true)
|
||||
setSelectedFile(null)
|
||||
setFileDiff(null)
|
||||
|
||||
window.flowgit.history
|
||||
.getCommit(repoPath, selectedHash)
|
||||
.then((res) => {
|
||||
if (!isMounted) return
|
||||
if (res.success && res.data) {
|
||||
setCommitDetail(res.data as CommitDetail)
|
||||
} else {
|
||||
setCommitDetail(null)
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!isMounted) return
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error loading commit details',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) setLoadingDetail(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [selectedHash, repoPath, showToast])
|
||||
|
||||
// Fetch diff when selectedFile in commitDetail changes
|
||||
useEffect(() => {
|
||||
if (!selectedFile || !selectedHash || !repoPath) {
|
||||
setFileDiff(null)
|
||||
return
|
||||
}
|
||||
|
||||
let isMounted = true
|
||||
setLoadingDiff(true)
|
||||
|
||||
window.flowgit.diff
|
||||
.file(repoPath, selectedFile, { ref: selectedHash })
|
||||
.then((res) => {
|
||||
if (!isMounted) return
|
||||
if (res.success && res.data) {
|
||||
setFileDiff(res.data as FileDiff)
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!isMounted) return
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error loading file diff',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (isMounted) setLoadingDiff(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [selectedFile, selectedHash, repoPath, showToast])
|
||||
|
||||
// Actions
|
||||
const handleCherryPick = () => {
|
||||
if (!selectedHash) return
|
||||
openConfirmModal({
|
||||
title: 'Cherry-pick Commit',
|
||||
message: `Apply commit ${selectedHash.substring(0, 7)} onto current HEAD?`,
|
||||
confirmText: 'Cherry-pick',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.commit.cherryPick(repoPath, selectedHash)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: 'Cherry-pick complete' })
|
||||
fetchLog()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Cherry-pick failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleRevert = () => {
|
||||
if (!selectedHash) return
|
||||
openConfirmModal({
|
||||
title: 'Revert Commit',
|
||||
message: `Create a reverting commit for ${selectedHash.substring(0, 7)}?`,
|
||||
confirmText: 'Revert',
|
||||
isDangerous: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.commit.revert(repoPath, selectedHash)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: 'Commit reverted' })
|
||||
fetchLog()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Revert failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCheckoutCommit = () => {
|
||||
if (!selectedHash) return
|
||||
openConfirmModal({
|
||||
title: 'Checkout Commit (Detached HEAD)',
|
||||
message: `Switch HEAD to commit ${selectedHash.substring(0, 7)}? Your repository will enter a detached HEAD state.`,
|
||||
confirmText: 'Checkout',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.branch.switch(repoPath, selectedHash)
|
||||
if (res.success) {
|
||||
showToast({ type: 'info', title: 'Switched to commit', message: selectedHash.substring(0, 7) })
|
||||
fetchLog()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Checkout failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateBranch = async () => {
|
||||
if (!selectedHash || !newBranchName.trim()) return
|
||||
try {
|
||||
const res = await window.flowgit.branch.create(repoPath, newBranchName.trim(), selectedHash)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: 'Branch created', message: newBranchName })
|
||||
setNewBranchModal(false)
|
||||
setNewBranchName('')
|
||||
fetchLog()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Failed to create branch', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#000000',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
>
|
||||
{/* ── Left: Commit List Panel ── */}
|
||||
<div
|
||||
style={{
|
||||
width: '500px',
|
||||
background: '#111111',
|
||||
borderRight: '1px solid #202020',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Search header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
background: '#141414',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Search commits by message, author, hash…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
style={{ fontSize: '12px', padding: '6px 10px' }}
|
||||
/>
|
||||
{loadingLog && <Spinner size="sm" />}
|
||||
</div>
|
||||
|
||||
{/* Scrollable Commit Rows */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{commits.length === 0 && !loadingLog && (
|
||||
<div style={{ padding: '32px', textAlign: 'center', color: '#606060', fontSize: '13px' }}>
|
||||
No commits found.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commits.map((c) => {
|
||||
const isSelected = selectedHash === c.hash
|
||||
return (
|
||||
<div
|
||||
key={c.hash}
|
||||
onClick={() => setSelectedHash(c.hash)}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
background: isSelected ? '#191919' : 'transparent',
|
||||
borderLeft: isSelected ? '2px solid #da5c2c' : '2px solid transparent',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
transition: 'background-color 0.08s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.background = '#141414'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
{/* Row 1: Hash + Message + Refs */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span
|
||||
style={{
|
||||
color: '#da5c2c',
|
||||
fontSize: '12px',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{c.shortHash}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: '#eeeeee',
|
||||
fontSize: '13px',
|
||||
fontWeight: isSelected ? 700 : 400,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={c.message}
|
||||
>
|
||||
{c.message}
|
||||
</span>
|
||||
|
||||
{/* Branch/tag badges */}
|
||||
{c.refs && c.refs.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
||||
{c.refs.map((ref, idx) => (
|
||||
<Tag key={idx} label={ref} size="sm" variant={ref.includes('tag:') ? 'tag' : 'branch'} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Author + Date */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '11px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#b4b4b4' }}>{c.author}</span>
|
||||
<span style={{ color: '#606060' }}>{c.authorDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: Commit Detail & Diff Panel ── */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{loadingDetail ? (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spinner size="md" label="Loading commit details…" />
|
||||
</div>
|
||||
) : commitDetail ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Commit Header & Actions Strip */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
background: '#141414',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: '16px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ color: '#eeeeee', fontSize: '15px', fontWeight: 700, marginBottom: '6px' }}>
|
||||
{commitDetail.message}
|
||||
</div>
|
||||
{commitDetail.body && (
|
||||
<div
|
||||
style={{
|
||||
color: '#b4b4b4',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
{commitDetail.body}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', fontSize: '12px' }}>
|
||||
<span style={{ color: '#7e7e7e' }}>
|
||||
Author: <span style={{ color: '#eeeeee' }}>{commitDetail.author}</span> <{commitDetail.authorEmail}>
|
||||
</span>
|
||||
<span style={{ color: '#606060' }}>{commitDetail.authorDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit Action Buttons */}
|
||||
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setNewBranchModal(true)}>
|
||||
+ Branch
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleCherryPick}>
|
||||
Cherry-pick
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleRevert}>
|
||||
Revert
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleCheckoutCommit}>
|
||||
Checkout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hash and Parents */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
fontSize: '11px',
|
||||
color: '#606060',
|
||||
paddingTop: '6px',
|
||||
borderTop: '1px solid #1e1e1e',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Commit:{' '}
|
||||
<span
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(commitDetail.hash)
|
||||
showToast({ type: 'info', title: 'Hash copied to clipboard' })
|
||||
}}
|
||||
style={{ color: '#da5c2c', cursor: 'pointer', textDecoration: 'underline' }}
|
||||
title="Click to copy full SHA"
|
||||
>
|
||||
{commitDetail.hash}
|
||||
</span>
|
||||
</span>
|
||||
{commitDetail.parents && commitDetail.parents.length > 0 && (
|
||||
<span>
|
||||
Parents:{' '}
|
||||
{commitDetail.parents.map((p) => (
|
||||
<span
|
||||
key={p}
|
||||
onClick={() => setSelectedHash(p)}
|
||||
style={{ color: '#61afef', cursor: 'pointer', marginRight: '6px' }}
|
||||
>
|
||||
{p.substring(0, 7)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ marginLeft: 'auto' }}>
|
||||
{commitDetail.stats?.files ?? commitDetail.files?.length ?? 0} files changed (
|
||||
<span style={{ color: '#4caf50' }}>+{commitDetail.stats?.additions ?? 0}</span>,{' '}
|
||||
<span style={{ color: '#ef5350' }}>-{commitDetail.stats?.deletions ?? 0}</span>)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changed files + Diff split */}
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{/* Changed files list (240px) */}
|
||||
<div
|
||||
style={{
|
||||
width: '240px',
|
||||
background: '#111111',
|
||||
borderRight: '1px solid #202020',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flexShrink: 0,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
fontSize: '11px',
|
||||
color: '#7e7e7e',
|
||||
borderBottom: '1px solid #202020',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
CHANGED FILES ({commitDetail.files?.length ?? 0})
|
||||
</div>
|
||||
{commitDetail.files?.map((f: CommitFile) => {
|
||||
const isFileSelected = selectedFile === f.path
|
||||
return (
|
||||
<div
|
||||
key={f.path}
|
||||
onClick={() => setSelectedFile(f.path)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: isFileSelected ? '#191919' : 'transparent',
|
||||
borderLeft: isFileSelected ? '2px solid #da5c2c' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '6px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isFileSelected) e.currentTarget.style.background = '#141414'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isFileSelected) e.currentTarget.style.background = 'transparent'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', overflow: 'hidden' }}>
|
||||
<FileStatusIcon status={f.status} />
|
||||
<span
|
||||
style={{
|
||||
color: isFileSelected ? '#eeeeee' : '#b4b4b4',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={f.path}
|
||||
>
|
||||
{f.path}
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '10px', display: 'inline-flex', gap: '4px', flexShrink: 0 }}>
|
||||
{f.additions > 0 && <span style={{ color: '#4caf50' }}>+{f.additions}</span>}
|
||||
{f.deletions > 0 && <span style={{ color: '#ef5350' }}>-{f.deletions}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Diff Viewer for selected file */}
|
||||
<div style={{ flex: 1, height: '100%', overflow: 'hidden' }}>
|
||||
<DiffViewer
|
||||
diff={fileDiff}
|
||||
filePath={selectedFile ?? undefined}
|
||||
isLoading={loadingDiff}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#606060',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Select a commit to view details
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Branch Modal */}
|
||||
{newBranchModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.75)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={() => setNewBranchModal(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#191919',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
padding: '20px',
|
||||
width: '380px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ color: '#eeeeee', fontSize: '15px', fontWeight: 700, marginBottom: '14px' }}>
|
||||
Create Branch from {selectedHash?.substring(0, 7)}
|
||||
</div>
|
||||
<Input
|
||||
label="BRANCH NAME"
|
||||
placeholder="feature/branch-name"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '16px' }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setNewBranchModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleCreateBranch} showArrow>
|
||||
Create Branch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HistoryView
|
||||
415
src/views/Repository/Remotes/RemotesView.tsx
Normal file
415
src/views/Repository/Remotes/RemotesView.tsx
Normal file
@ -0,0 +1,415 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useRepoStore } from '../../../store/repo.store'
|
||||
import { useUiStore } from '../../../store/ui.store'
|
||||
import Button from '../../../components/Button'
|
||||
import Input from '../../../components/Input'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import Tag from '../../../components/Tag'
|
||||
import type { Remote, RepoStatus } from '@shared/types/git.types'
|
||||
|
||||
export interface RemotesViewProps {
|
||||
repoPath: string
|
||||
}
|
||||
|
||||
export const RemotesView: React.FC<RemotesViewProps> = ({ repoPath }) => {
|
||||
const { status, setStatus } = useRepoStore()
|
||||
const { showToast, openConfirmModal } = useUiStore()
|
||||
|
||||
const [remotes, setRemotes] = useState<Remote[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeOp, setActiveOp] = useState<string | null>(null)
|
||||
|
||||
// Add Remote modal
|
||||
const [addModal, setAddModal] = useState(false)
|
||||
const [newRemoteName, setNewRemoteName] = useState('')
|
||||
const [newRemoteUrl, setNewRemoteUrl] = useState('')
|
||||
|
||||
const loadRemotes = useCallback(async () => {
|
||||
if (!repoPath) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const [remoteRes, statusRes] = await Promise.all([
|
||||
window.flowgit.remote.list(repoPath),
|
||||
window.flowgit.repo.getStatus(repoPath),
|
||||
])
|
||||
|
||||
if (remoteRes.success && remoteRes.data) {
|
||||
setRemotes(remoteRes.data as Remote[])
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Cannot load remotes', message: remoteRes.error })
|
||||
}
|
||||
|
||||
if (statusRes.success && statusRes.data) {
|
||||
setStatus(statusRes.data as RepoStatus)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error loading remotes',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [repoPath, setStatus, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
loadRemotes()
|
||||
}, [loadRemotes])
|
||||
|
||||
// Remote operations
|
||||
const handleFetch = async (remoteName?: string) => {
|
||||
const opKey = `fetch-${remoteName ?? 'all'}`
|
||||
setActiveOp(opKey)
|
||||
try {
|
||||
const res = await window.flowgit.remote.fetch(repoPath, remoteName)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Fetch completed (${remoteName ?? 'all remotes'})` })
|
||||
loadRemotes()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Fetch failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Fetch error', message: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setActiveOp(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePull = async (remoteName?: string) => {
|
||||
const opKey = `pull-${remoteName ?? 'current'}`
|
||||
setActiveOp(opKey)
|
||||
try {
|
||||
const res = await window.flowgit.remote.pull(repoPath, remoteName)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: 'Pull completed successfully' })
|
||||
loadRemotes()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Pull failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Pull error', message: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setActiveOp(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePush = async (remoteName?: string) => {
|
||||
const opKey = `push-${remoteName ?? 'current'}`
|
||||
setActiveOp(opKey)
|
||||
try {
|
||||
const res = await window.flowgit.remote.push(repoPath, remoteName)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: 'Push completed successfully' })
|
||||
loadRemotes()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Push failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Push error', message: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setActiveOp(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddRemote = async () => {
|
||||
if (!newRemoteName.trim() || !newRemoteUrl.trim()) {
|
||||
showToast({ type: 'warning', title: 'Missing Fields', message: 'Name and URL are required.' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await window.flowgit.remote.add(repoPath, newRemoteName.trim(), newRemoteUrl.trim())
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Added remote "${newRemoteName.trim()}"` })
|
||||
setAddModal(false)
|
||||
setNewRemoteName('')
|
||||
setNewRemoteUrl('')
|
||||
loadRemotes()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Failed to add remote', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveRemote = (remote: Remote) => {
|
||||
openConfirmModal({
|
||||
title: 'Remove Remote',
|
||||
message: `Are you sure you want to remove remote "${remote.name}"? Tracking branches associated with this remote will be affected.`,
|
||||
confirmText: 'Remove Remote',
|
||||
isDangerous: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.remote.remove(repoPath, remote.name)
|
||||
if (res.success) {
|
||||
showToast({ type: 'info', title: `Removed remote "${remote.name}"` })
|
||||
loadRemotes()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Remove remote failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#000000',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
>
|
||||
{/* Top Header Bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: '#141414',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: '#eeeeee', letterSpacing: '0.04em' }}>
|
||||
REMOTES & TRACKING
|
||||
</span>
|
||||
{loading && <Spinner size="sm" />}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleFetch()}
|
||||
loading={activeOp === 'fetch-all'}
|
||||
>
|
||||
Fetch All
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setAddModal(true)}
|
||||
showArrow
|
||||
>
|
||||
+ Add Remote
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tracking info strip for current branch */}
|
||||
{status?.branch && (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: '#161616',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<span style={{ color: '#7e7e7e', fontSize: '12px' }}>Current Branch Tracking:</span>
|
||||
<Tag label={status.branch} variant="branch" size="sm" />
|
||||
<span style={{ fontSize: '12px', display: 'inline-flex', gap: '8px' }}>
|
||||
<span style={{ color: status.ahead > 0 ? '#4caf50' : '#606060' }}>
|
||||
↑ {status.ahead} ahead
|
||||
</span>
|
||||
<span style={{ color: status.behind > 0 ? '#ef5350' : '#606060' }}>
|
||||
↓ {status.behind} behind
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePull()}
|
||||
loading={activeOp === 'pull-current'}
|
||||
>
|
||||
Pull ↓
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => handlePush()}
|
||||
loading={activeOp === 'push-current'}
|
||||
showArrow
|
||||
>
|
||||
Push ↑
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remotes List */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 20px' }}>
|
||||
{remotes.length === 0 && !loading && (
|
||||
<div
|
||||
style={{
|
||||
padding: '48px 20px',
|
||||
textAlign: 'center',
|
||||
color: '#606060',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
No remotes configured for this repository. Click "+ Add Remote" to connect to GitHub, GitLab, or a remote server.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{remotes.map((r) => (
|
||||
<div
|
||||
key={r.name}
|
||||
style={{
|
||||
background: '#111111',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<Tag label={r.name} variant="remote" size="md" />
|
||||
<span style={{ color: '#da5c2c', fontSize: '13px', fontWeight: 700 }}>
|
||||
{r.name === 'origin' ? '[DEFAULT REMOTE]' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleFetch(r.name)}
|
||||
loading={activeOp === `fetch-${r.name}`}
|
||||
>
|
||||
Fetch
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handlePull(r.name)}
|
||||
loading={activeOp === `pull-${r.name}`}
|
||||
>
|
||||
Pull
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handlePush(r.name)}
|
||||
loading={activeOp === `push-${r.name}`}
|
||||
showArrow
|
||||
>
|
||||
Push
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveRemote(r)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URLs */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
fontSize: '12px',
|
||||
background: '#161616',
|
||||
padding: '10px 12px',
|
||||
borderRadius: '2px',
|
||||
border: '1px solid #1f1f1f',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<span style={{ color: '#7e7e7e', width: '50px', flexShrink: 0 }}>FETCH:</span>
|
||||
<span style={{ color: '#eeeeee', wordBreak: 'break-all' }}>{r.fetchUrl}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<span style={{ color: '#7e7e7e', width: '50px', flexShrink: 0 }}>PUSH:</span>
|
||||
<span style={{ color: '#eeeeee', wordBreak: 'break-all' }}>{r.pushUrl}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Remote Modal */}
|
||||
{addModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.75)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
onClick={() => setAddModal(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#191919',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
padding: '24px',
|
||||
width: '440px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ color: '#eeeeee', fontSize: '15px', fontWeight: 700, marginBottom: '16px' }}>
|
||||
Add Remote
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<Input
|
||||
label="REMOTE NAME"
|
||||
placeholder="origin, upstream, backup…"
|
||||
value={newRemoteName}
|
||||
onChange={(e) => setNewRemoteName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label="REMOTE URL"
|
||||
placeholder="https://github.com/owner/repo.git or git@github.com:owner/repo.git"
|
||||
value={newRemoteUrl}
|
||||
onChange={(e) => setNewRemoteUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '20px' }}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setAddModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={handleAddRemote} showArrow>
|
||||
Add Remote
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RemotesView
|
||||
87
src/views/Repository/RepositoryWorkspace.tsx
Normal file
87
src/views/Repository/RepositoryWorkspace.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { useEffect, useCallback } from 'react'
|
||||
import { useRepoStore } from '../../store/repo.store'
|
||||
import { useUiStore } from '../../store/ui.store'
|
||||
import Sidebar from '../../components/Sidebar'
|
||||
import TitleBar from '../../components/TitleBar'
|
||||
import ChangesView from './Changes/ChangesView'
|
||||
import HistoryView from './History/HistoryView'
|
||||
import GraphView from './Graph/GraphView'
|
||||
import BranchesView from './Branches/BranchesView'
|
||||
import StashView from './Stash/StashView'
|
||||
import RemotesView from './Remotes/RemotesView'
|
||||
import FileBrowserView from './FileBrowser/FileBrowserView'
|
||||
import type { RepoStatus } from '@shared/types/git.types'
|
||||
|
||||
export const RepositoryWorkspace: React.FC = () => {
|
||||
const { activeRepoPath, setStatus } = useRepoStore()
|
||||
const { activeView, showToast } = useUiStore()
|
||||
|
||||
// Poll or refresh status periodically/on mount
|
||||
const refreshStatus = useCallback(async () => {
|
||||
if (!activeRepoPath) return
|
||||
try {
|
||||
const res = await window.flowgit.repo.getStatus(activeRepoPath)
|
||||
if (res.success && res.data) {
|
||||
setStatus(res.data as RepoStatus)
|
||||
}
|
||||
} catch {
|
||||
// Ignore background refresh errors
|
||||
}
|
||||
}, [activeRepoPath, setStatus])
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus()
|
||||
// Periodic light status poll every 15s
|
||||
const timer = setInterval(refreshStatus, 15000)
|
||||
return () => clearInterval(timer)
|
||||
}, [refreshStatus])
|
||||
|
||||
if (!activeRepoPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
overflow: 'hidden',
|
||||
background: '#000000',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
>
|
||||
{/* Top Title Bar */}
|
||||
<TitleBar />
|
||||
|
||||
{/* Main Workspace: Left Sidebar + Right Active View */}
|
||||
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||
{/* Left: Sidebar (240px) */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Right: Active View */}
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#000000',
|
||||
}}
|
||||
>
|
||||
{activeView === 'changes' && <ChangesView repoPath={activeRepoPath} />}
|
||||
{activeView === 'history' && <HistoryView repoPath={activeRepoPath} />}
|
||||
{activeView === 'graph' && <GraphView repoPath={activeRepoPath} />}
|
||||
{activeView === 'branches' && <BranchesView repoPath={activeRepoPath} />}
|
||||
{activeView === 'stash' && <StashView repoPath={activeRepoPath} />}
|
||||
{activeView === 'remotes' && <RemotesView repoPath={activeRepoPath} />}
|
||||
{activeView === 'filebrowser' && <FileBrowserView repoPath={activeRepoPath} />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RepositoryWorkspace
|
||||
304
src/views/Repository/Stash/StashView.tsx
Normal file
304
src/views/Repository/Stash/StashView.tsx
Normal file
@ -0,0 +1,304 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useUiStore } from '../../../store/ui.store'
|
||||
import Button from '../../../components/Button'
|
||||
import Input from '../../../components/Input'
|
||||
import Spinner from '../../../components/Spinner'
|
||||
import Tag from '../../../components/Tag'
|
||||
import type { StashEntry } from '@shared/types/git.types'
|
||||
|
||||
export interface StashViewProps {
|
||||
repoPath: string
|
||||
}
|
||||
|
||||
export const StashView: React.FC<StashViewProps> = ({ repoPath }) => {
|
||||
const { showToast, openConfirmModal } = useUiStore()
|
||||
|
||||
const [stashes, setStashes] = useState<StashEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// New Stash Form
|
||||
const [newStashOpen, setNewStashOpen] = useState(false)
|
||||
const [stashMessage, setStashMessage] = useState('')
|
||||
const [includeUntracked, setIncludeUntracked] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const loadStashes = useCallback(async () => {
|
||||
if (!repoPath) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await window.flowgit.stash.list(repoPath)
|
||||
if (res.success && res.data) {
|
||||
setStashes(res.data as StashEntry[])
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Cannot list stashes', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error loading stash',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [repoPath, showToast])
|
||||
|
||||
useEffect(() => {
|
||||
loadStashes()
|
||||
}, [loadStashes])
|
||||
|
||||
const handleCreateStash = async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await window.flowgit.stash.create(
|
||||
repoPath,
|
||||
stashMessage.trim() || undefined,
|
||||
includeUntracked
|
||||
)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: 'Changes stashed successfully' })
|
||||
setNewStashOpen(false)
|
||||
setStashMessage('')
|
||||
loadStashes()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Stash creation failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyStash = async (index: number) => {
|
||||
try {
|
||||
const res = await window.flowgit.stash.apply(repoPath, index)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Stash@{${index}} applied` })
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Apply stash failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handlePopStash = async (index: number) => {
|
||||
try {
|
||||
const res = await window.flowgit.stash.pop(repoPath, index)
|
||||
if (res.success) {
|
||||
showToast({ type: 'success', title: `Stash@{${index}} popped` })
|
||||
loadStashes()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Pop stash failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDropStash = (index: number, message: string) => {
|
||||
openConfirmModal({
|
||||
title: 'Drop Stash Entry',
|
||||
message: `Are you sure you want to drop stash@{${index}} ("${message}")? This change cannot be recovered.`,
|
||||
confirmText: 'Drop Stash',
|
||||
isDangerous: true,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
const res = await window.flowgit.stash.drop(repoPath, index)
|
||||
if (res.success) {
|
||||
showToast({ type: 'info', title: `Dropped stash@{${index}}` })
|
||||
loadStashes()
|
||||
} else {
|
||||
showToast({ type: 'error', title: 'Drop failed', message: res.error })
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showToast({ type: 'error', title: 'Error', message: e instanceof Error ? e.message : String(e) })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#000000',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
>
|
||||
{/* Top Header Bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
background: '#141414',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: '#eeeeee', letterSpacing: '0.04em' }}>
|
||||
STASH ({stashes.length})
|
||||
</span>
|
||||
{loading && <Spinner size="sm" />}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setNewStashOpen(!newStashOpen)}
|
||||
showArrow
|
||||
>
|
||||
{newStashOpen ? 'Cancel' : '+ New Stash'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Expandable New Stash Form */}
|
||||
{newStashOpen && (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
background: '#161616',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '12px', fontWeight: 700, color: '#eeeeee' }}>
|
||||
Create New Stash Entry
|
||||
</div>
|
||||
<Input
|
||||
label="STASH MESSAGE (OPTIONAL)"
|
||||
placeholder="WIP on feature branch…"
|
||||
value={stashMessage}
|
||||
onChange={(e) => setStashMessage(e.target.value)}
|
||||
disabled={creating}
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#b4b4b4',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeUntracked}
|
||||
onChange={(e) => setIncludeUntracked(e.target.checked)}
|
||||
style={{ accentColor: '#da5c2c' }}
|
||||
disabled={creating}
|
||||
/>
|
||||
<span>Include untracked files (-u)</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleCreateStash}
|
||||
loading={creating}
|
||||
showArrow
|
||||
>
|
||||
Stash Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stash List */}
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{stashes.length === 0 && !loading && (
|
||||
<div
|
||||
style={{
|
||||
padding: '48px 20px',
|
||||
textAlign: 'center',
|
||||
color: '#606060',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
No stash entries found. Stash saves your uncommitted changes for later.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stashes.map((s) => (
|
||||
<div
|
||||
key={s.index}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
borderBottom: '1px solid #1a1a1a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Tag label={`stash@{${s.index}}`} variant="stash" size="sm" />
|
||||
<span style={{ color: '#eeeeee', fontSize: '13px', fontWeight: 700 }}>
|
||||
{s.message}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
fontSize: '11px',
|
||||
color: '#606060',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
<span>Branch: <span style={{ color: '#b4b4b4' }}>{s.branch}</span></span>
|
||||
<span>Date: <span style={{ color: '#7e7e7e' }}>{s.date}</span></span>
|
||||
<span>SHA: <span style={{ color: '#da5c2c' }}>{s.hash?.substring(0, 7)}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions per entry: Apply, Pop, Drop */}
|
||||
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleApplyStash(s.index)}
|
||||
title="Apply changes without removing from stash"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handlePopStash(s.index)}
|
||||
title="Apply changes and delete from stash"
|
||||
>
|
||||
Pop
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDropStash(s.index, s.message)}
|
||||
title="Discard this stash entry"
|
||||
>
|
||||
Drop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StashView
|
||||
237
src/views/Settings/SettingsView.tsx
Normal file
237
src/views/Settings/SettingsView.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUiStore } from '../../store/ui.store'
|
||||
import TitleBar from '../../components/TitleBar'
|
||||
import Button from '../../components/Button'
|
||||
import Input from '../../components/Input'
|
||||
import Spinner from '../../components/Spinner'
|
||||
import type { AppSettings } from '@shared/types/ipc.types'
|
||||
|
||||
export const SettingsView: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { showToast } = useUiStore()
|
||||
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
window.flowgit.settings
|
||||
.get()
|
||||
.then((data) => {
|
||||
setSettings(data)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Failed to load settings',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}, [showToast])
|
||||
|
||||
const handleUpdate = async <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
||||
if (!settings) return
|
||||
setSettings({ ...settings, [key]: value })
|
||||
try {
|
||||
await window.flowgit.settings.set(key, value)
|
||||
showToast({ type: 'success', title: 'Setting saved' })
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error updating setting',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
overflow: 'hidden',
|
||||
background: '#000000',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
}}
|
||||
>
|
||||
<TitleBar showRepoSwitcher={false} />
|
||||
|
||||
{/* Header bar */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 32px',
|
||||
background: '#141414',
|
||||
borderBottom: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#da5c2c',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span style={{ color: '#3a3a3a' }}>/</span>
|
||||
<span style={{ fontSize: '15px', fontWeight: 700, color: '#eeeeee' }}>SETTINGS</span>
|
||||
</div>
|
||||
|
||||
{loading && <Spinner size="sm" />}
|
||||
</div>
|
||||
|
||||
{/* Settings Form Body */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '32px', maxWidth: '800px', margin: '0 auto', width: '100%' }}>
|
||||
{settings ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '28px' }}>
|
||||
{/* Git binary configuration */}
|
||||
<div style={sectionBoxStyle}>
|
||||
<div style={sectionTitleStyle}>Git Binary Path</div>
|
||||
<div style={sectionDescStyle}>
|
||||
Leave blank to automatically detect git from your system PATH.
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Auto-detect (system PATH)"
|
||||
value={settings.gitPath ?? ''}
|
||||
onChange={(e) => handleUpdate('gitPath', e.target.value.trim() || null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* General Preferences */}
|
||||
<div style={sectionBoxStyle}>
|
||||
<div style={sectionTitleStyle}>Behavior & Safety</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', marginTop: '12px' }}>
|
||||
<label style={checkboxLabelStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.confirmDestructive}
|
||||
onChange={(e) => handleUpdate('confirmDestructive', e.target.checked)}
|
||||
style={{ accentColor: '#da5c2c' }}
|
||||
/>
|
||||
<span>Confirm before destructive operations (discard, delete branch, drop stash)</span>
|
||||
</label>
|
||||
|
||||
<label style={checkboxLabelStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.autoFetch}
|
||||
onChange={(e) => handleUpdate('autoFetch', e.target.checked)}
|
||||
style={{ accentColor: '#da5c2c' }}
|
||||
/>
|
||||
<span>Automatically fetch from remotes in background</span>
|
||||
</label>
|
||||
|
||||
<label style={checkboxLabelStyle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.showHiddenFiles}
|
||||
onChange={(e) => handleUpdate('showHiddenFiles', e.target.checked)}
|
||||
style={{ accentColor: '#da5c2c' }}
|
||||
/>
|
||||
<span>Show hidden dotfiles (.gitignore, .env, etc.)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pull Strategy */}
|
||||
<div style={sectionBoxStyle}>
|
||||
<div style={sectionTitleStyle}>Default Pull Strategy</div>
|
||||
<div style={sectionDescStyle}>
|
||||
How FlowGit integrates remote branch updates when pulling.
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '16px', marginTop: '10px' }}>
|
||||
{(['merge', 'rebase', 'ff-only'] as const).map((strat) => (
|
||||
<label key={strat} style={{ ...checkboxLabelStyle, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="pullStrategy"
|
||||
checked={settings.pullStrategy === strat}
|
||||
onChange={() => handleUpdate('pullStrategy', strat)}
|
||||
style={{ accentColor: '#da5c2c' }}
|
||||
/>
|
||||
<span style={{ textTransform: 'uppercase', fontSize: '12px' }}>{strat}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme & Aesthetics */}
|
||||
<div style={sectionBoxStyle}>
|
||||
<div style={sectionTitleStyle}>Appearance</div>
|
||||
<div style={sectionDescStyle}>
|
||||
FlowGit uses Axiom Dark Design Language exclusively. Monospaced typography with BerkeleyMono.
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginTop: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
background: '#111111',
|
||||
border: '1px solid #da5c2c',
|
||||
borderRadius: '2px',
|
||||
padding: '8px 16px',
|
||||
color: '#eeeeee',
|
||||
fontSize: '12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#da5c2c' }}>●</span>
|
||||
<span>Void Dark (#000000)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionBoxStyle: React.CSSProperties = {
|
||||
background: '#111111',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
padding: '20px',
|
||||
}
|
||||
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
color: '#eeeeee',
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.02em',
|
||||
}
|
||||
|
||||
const sectionDescStyle: React.CSSProperties = {
|
||||
color: '#7e7e7e',
|
||||
fontSize: '12px',
|
||||
marginTop: '4px',
|
||||
marginBottom: '12px',
|
||||
lineHeight: 1.5,
|
||||
}
|
||||
|
||||
const checkboxLabelStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
fontSize: '13px',
|
||||
color: '#b4b4b4',
|
||||
cursor: 'pointer',
|
||||
}
|
||||
|
||||
export default SettingsView
|
||||
562
src/views/Welcome/WelcomeView.tsx
Normal file
562
src/views/Welcome/WelcomeView.tsx
Normal file
@ -0,0 +1,562 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useRepoStore } from '../../store/repo.store'
|
||||
import { useUiStore } from '../../store/ui.store'
|
||||
import TitleBar from '../../components/TitleBar'
|
||||
import Button from '../../components/Button'
|
||||
import Input from '../../components/Input'
|
||||
import Spinner from '../../components/Spinner'
|
||||
import type { Repository } from '@shared/types/git.types'
|
||||
|
||||
export const WelcomeView: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { repositories, setActiveRepo, addRepository, gitFound, gitVersion, gitPath } = useRepoStore()
|
||||
const { showToast } = useUiStore()
|
||||
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [cloneUrl, setCloneUrl] = useState('')
|
||||
const [clonePath, setClonePath] = useState('')
|
||||
const [loadingAction, setLoadingAction] = useState<string | null>(null)
|
||||
|
||||
const handleOpenRepo = useCallback(async () => {
|
||||
setLoadingAction('open')
|
||||
try {
|
||||
const selectedDir = await window.flowgit.openDirectory({ title: 'Open Existing Git Repository' })
|
||||
if (!selectedDir) {
|
||||
setLoadingAction(null)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await window.flowgit.repo.open(selectedDir)
|
||||
if (!res.success || !res.data) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Cannot Open Repository',
|
||||
message: res.error || 'The selected folder is not a valid Git repository.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const repo = res.data as Repository
|
||||
addRepository(repo)
|
||||
setActiveRepo(repo)
|
||||
navigate('/repo')
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error Opening Repository',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
}, [addRepository, setActiveRepo, navigate, showToast])
|
||||
|
||||
const handleInitRepo = useCallback(async () => {
|
||||
setLoadingAction('init')
|
||||
try {
|
||||
const selectedDir = await window.flowgit.openDirectory({ title: 'Select Directory to Initialize Git' })
|
||||
if (!selectedDir) {
|
||||
setLoadingAction(null)
|
||||
return
|
||||
}
|
||||
|
||||
const res = await window.flowgit.repo.init(selectedDir)
|
||||
if (!res.success || !res.data) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Initialization Failed',
|
||||
message: res.error || 'Failed to initialize git repository in selected folder.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const repo = res.data as Repository
|
||||
addRepository(repo)
|
||||
setActiveRepo(repo)
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Repository Initialized',
|
||||
message: `Created Git repository at ${selectedDir}`,
|
||||
})
|
||||
navigate('/repo')
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error Initializing Repository',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
}, [addRepository, setActiveRepo, navigate, showToast])
|
||||
|
||||
const handleBrowseClonePath = useCallback(async () => {
|
||||
const selected = await window.flowgit.openDirectory({ title: 'Select Target Directory For Clone' })
|
||||
if (selected) {
|
||||
setClonePath(selected)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCloneRepo = useCallback(async () => {
|
||||
if (!cloneUrl.trim()) {
|
||||
showToast({ type: 'warning', title: 'URL Required', message: 'Please enter a Git repository URL.' })
|
||||
return
|
||||
}
|
||||
if (!clonePath.trim()) {
|
||||
showToast({ type: 'warning', title: 'Target Directory Required', message: 'Please specify where to clone the repository.' })
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingAction('clone')
|
||||
try {
|
||||
const res = await window.flowgit.repo.clone(cloneUrl.trim(), clonePath.trim())
|
||||
if (!res.success || !res.data) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Clone Failed',
|
||||
message: res.error || 'Could not clone repository from the specified URL.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const repo = res.data as Repository
|
||||
addRepository(repo)
|
||||
setActiveRepo(repo)
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: 'Clone Complete',
|
||||
message: `Cloned into ${clonePath}`,
|
||||
})
|
||||
navigate('/repo')
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Clone Exception',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
}, [cloneUrl, clonePath, addRepository, setActiveRepo, navigate, showToast])
|
||||
|
||||
const handleOpenRecent = useCallback(
|
||||
async (repo: Repository) => {
|
||||
setLoadingAction(repo.id)
|
||||
try {
|
||||
const res = await window.flowgit.repo.open(repo.path)
|
||||
if (!res.success) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Repository Not Accessible',
|
||||
message: res.error || `Could not open ${repo.path}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
setActiveRepo(repo)
|
||||
navigate('/repo')
|
||||
} catch (e: unknown) {
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: 'Error Opening Repository',
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
})
|
||||
} finally {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[setActiveRepo, navigate, showToast]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
background: '#000000',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<TitleBar showRepoSwitcher={false} />
|
||||
|
||||
{/* Main Container */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflowY: 'auto',
|
||||
padding: '40px 24px',
|
||||
}}
|
||||
>
|
||||
{/* Hero Section */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '40px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#606060',
|
||||
marginBottom: '10px',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
~/FLOWGIT/TERMINAL
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '32px',
|
||||
fontWeight: 700,
|
||||
color: '#eeeeee',
|
||||
letterSpacing: '-0.02em',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
FlowGit
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#b4b4b4',
|
||||
maxWidth: '460px',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
A high-performance, Blueprint-inspired Git desktop client designed for precision.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Cards */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
width: '100%',
|
||||
maxWidth: '560px',
|
||||
marginBottom: '40px',
|
||||
}}
|
||||
>
|
||||
{/* Card 1: Open Repository */}
|
||||
<div
|
||||
onClick={handleOpenRepo}
|
||||
style={{
|
||||
background: '#191919',
|
||||
border: '1px solid #202020',
|
||||
borderLeft: '2px solid #da5c2c',
|
||||
borderRadius: '2px',
|
||||
padding: '18px 24px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'background-color 0.12s ease, border-color 0.12s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#202020')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = '#191919')}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
color: '#eeeeee',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>Open Repository</span>
|
||||
<span style={{ color: '#da5c2c' }}>→</span>
|
||||
</div>
|
||||
<div style={{ color: '#7e7e7e', fontSize: '12px', marginTop: '4px' }}>
|
||||
Open an existing local Git repository from your disk
|
||||
</div>
|
||||
</div>
|
||||
{loadingAction === 'open' && <Spinner size="sm" />}
|
||||
</div>
|
||||
|
||||
{/* Card 2: Clone Repository */}
|
||||
<div
|
||||
style={{
|
||||
background: '#191919',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.12s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => setCloneOpen(!cloneOpen)}
|
||||
style={{
|
||||
padding: '18px 24px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderLeft: cloneOpen ? '2px solid #da5c2c' : '2px solid transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#202020')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
color: '#eeeeee',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>Clone Repository</span>
|
||||
<span style={{ color: '#b4b4b4' }}>{cloneOpen ? '↓' : '→'}</span>
|
||||
</div>
|
||||
<div style={{ color: '#7e7e7e', fontSize: '12px', marginTop: '4px' }}>
|
||||
Clone from a remote Git URL (GitHub, GitLab, SSH, or HTTPS)
|
||||
</div>
|
||||
</div>
|
||||
{loadingAction === 'clone' && <Spinner size="sm" />}
|
||||
</div>
|
||||
|
||||
{/* Expandable Clone Form */}
|
||||
{cloneOpen && (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px 20px',
|
||||
borderTop: '1px solid #202020',
|
||||
background: '#141414',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label="REPOSITORY URL"
|
||||
placeholder="https://github.com/owner/repository.git"
|
||||
value={cloneUrl}
|
||||
onChange={(e) => setCloneUrl(e.target.value)}
|
||||
disabled={loadingAction === 'clone'}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
fontSize: '12px',
|
||||
color: '#b4b4b4',
|
||||
display: 'block',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
TARGET DIRECTORY
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<Input
|
||||
placeholder="c:\projects\my-repo"
|
||||
value={clonePath}
|
||||
onChange={(e) => setClonePath(e.target.value)}
|
||||
disabled={loadingAction === 'clone'}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={handleBrowseClonePath}
|
||||
disabled={loadingAction === 'clone'}
|
||||
type="button"
|
||||
>
|
||||
Browse…
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '6px' }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleCloneRepo}
|
||||
loading={loadingAction === 'clone'}
|
||||
showArrow
|
||||
>
|
||||
Clone
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card 3: Initialize Repository */}
|
||||
<div
|
||||
onClick={handleInitRepo}
|
||||
style={{
|
||||
background: '#191919',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
padding: '18px 24px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
transition: 'background-color 0.12s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#202020')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = '#191919')}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
color: '#eeeeee',
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span>Initialize Repository</span>
|
||||
<span style={{ color: '#b4b4b4' }}>→</span>
|
||||
</div>
|
||||
<div style={{ color: '#7e7e7e', fontSize: '12px', marginTop: '4px' }}>
|
||||
Create a new Git repository in an existing directory
|
||||
</div>
|
||||
</div>
|
||||
{loadingAction === 'init' && <Spinner size="sm" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Repositories Section */}
|
||||
{repositories.length > 0 && (
|
||||
<div style={{ width: '100%', maxWidth: '560px' }}>
|
||||
<div
|
||||
style={{
|
||||
color: '#606060',
|
||||
fontSize: '11px',
|
||||
marginBottom: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.06em',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Recent Repositories
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
{repositories.slice(0, 8).map((repo) => (
|
||||
<div
|
||||
key={repo.id}
|
||||
onClick={() => handleOpenRecent(repo)}
|
||||
style={{
|
||||
background: '#111111',
|
||||
border: '1px solid #202020',
|
||||
borderRadius: '2px',
|
||||
padding: '10px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.1s ease, background-color 0.1s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#3a3a3a'
|
||||
e.currentTarget.style.background = '#141414'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#202020'
|
||||
e.currentTarget.style.background = '#111111'
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1, paddingRight: '12px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ color: '#eeeeee', fontSize: '13px', fontWeight: 700 }}>
|
||||
{repo.name}
|
||||
</span>
|
||||
{repo.currentBranch && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: '#61afef',
|
||||
background: '#141c24',
|
||||
border: '1px solid #1e2c3a',
|
||||
borderRadius: '9999px',
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
{repo.currentBranch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: '#606060',
|
||||
fontSize: '11px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: '2px',
|
||||
}}
|
||||
>
|
||||
{repo.path}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
||||
{loadingAction === repo.id ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<span style={{ color: '#505050', fontSize: '13px' }}>→</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Git Detection Status Strip */}
|
||||
<div
|
||||
style={{
|
||||
height: '28px',
|
||||
background: '#0d0d0d',
|
||||
borderTop: '1px solid #202020',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
fontSize: '11px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{gitFound ? (
|
||||
<span style={{ color: '#4caf50', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>✓</span>
|
||||
<span>Git {gitVersion} detected</span>
|
||||
{gitPath && <span style={{ color: '#505050' }}>({gitPath})</span>}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: '#da5c2c', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>✗</span>
|
||||
<span>Git binary not found in PATH</span>
|
||||
<a
|
||||
href="https://git-scm.com/downloads"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: '#da5c2c', textDecoration: 'underline', marginLeft: '6px' }}
|
||||
>
|
||||
Install Git →
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ color: '#505050' }}>Void Theme · Axiom Design System</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WelcomeView
|
||||
164
tests/git/git.test.ts
Normal file
164
tests/git/git.test.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { mkdtemp, rm, writeFile, mkdir } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { execGit, execGitOrThrow } from '../../electron/git/executor'
|
||||
import { detectGit } from '../../electron/git/detector'
|
||||
import { getRepoStatus, stageFile, unstageFile, stageAll, unstageAll, discardFile } from '../../electron/git/changes'
|
||||
import { getLog, getGraphData, getCommitDetail } from '../../electron/git/history'
|
||||
import { getFileDiff } from '../../electron/git/diff'
|
||||
import { listBranches, createBranch, switchBranch, deleteBranch, listTags, createTag } from '../../electron/git/branches'
|
||||
import { listStash, createStash, applyStash, popStash, dropStash } from '../../electron/git/stash'
|
||||
|
||||
describe.sequential('Git Engine Integration Tests (Isolated Temporary Repositories)', () => {
|
||||
let testDir: string
|
||||
let remoteDir: string
|
||||
|
||||
beforeAll(async () => {
|
||||
// Verify Git is detected
|
||||
const detection = await detectGit()
|
||||
expect(detection.found).toBe(true)
|
||||
expect(detection.version).toBeDefined()
|
||||
|
||||
// Create temp working dir
|
||||
testDir = await mkdtemp(join(tmpdir(), 'flowgit-test-repo-'))
|
||||
remoteDir = await mkdtemp(join(tmpdir(), 'flowgit-remote-repo-'))
|
||||
|
||||
// Initialize git repo with test user config
|
||||
await execGitOrThrow(['init', '-b', 'main'], { cwd: testDir })
|
||||
await execGitOrThrow(['config', 'user.name', 'FlowGit Tester'], { cwd: testDir })
|
||||
await execGitOrThrow(['config', 'user.email', 'tester@flowgit.local'], { cwd: testDir })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup temporary folders safely
|
||||
try {
|
||||
await rm(testDir, { recursive: true, force: true })
|
||||
await rm(remoteDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore Windows directory lock cleanup failures
|
||||
}
|
||||
})
|
||||
|
||||
it('1. Detects an empty valid repository status', async () => {
|
||||
const status = await getRepoStatus(testDir)
|
||||
expect(status.branch).toBe('main')
|
||||
expect(status.staged.length).toBe(0)
|
||||
expect(status.unstaged.length).toBe(0)
|
||||
expect(status.untracked.length).toBe(0)
|
||||
})
|
||||
|
||||
it('2. Detects untracked file, stages, and unstages it', async () => {
|
||||
const testFile = 'test.txt'
|
||||
await writeFile(join(testDir, testFile), 'Hello FlowGit\n', 'utf8')
|
||||
|
||||
let status = await getRepoStatus(testDir)
|
||||
expect(status.untracked.some(f => f.path === testFile)).toBe(true)
|
||||
|
||||
// Stage
|
||||
await stageFile(testDir, testFile)
|
||||
status = await getRepoStatus(testDir)
|
||||
expect(status.staged.some(f => f.path === testFile)).toBe(true)
|
||||
expect(status.untracked.some(f => f.path === testFile)).toBe(false)
|
||||
|
||||
// Unstage
|
||||
await unstageFile(testDir, testFile)
|
||||
status = await getRepoStatus(testDir)
|
||||
expect(status.staged.some(f => f.path === testFile)).toBe(false)
|
||||
expect(status.untracked.some(f => f.path === testFile)).toBe(true)
|
||||
})
|
||||
|
||||
it('3. Commits staged files and reads commit log', async () => {
|
||||
const testFile = 'test.txt'
|
||||
await stageFile(testDir, testFile)
|
||||
await execGitOrThrow(['commit', '-m', 'feat: initial commit'], { cwd: testDir })
|
||||
|
||||
const log = await getLog(testDir)
|
||||
expect(log.length).toBe(1)
|
||||
expect(log[0].message).toBe('feat: initial commit')
|
||||
expect(log[0].author).toBe('FlowGit Tester')
|
||||
expect(log[0].shortHash.length).toBeGreaterThanOrEqual(7)
|
||||
|
||||
const detail = await getCommitDetail(testDir, log[0].hash)
|
||||
expect(detail).not.toBeNull()
|
||||
expect(detail?.message).toBe('feat: initial commit')
|
||||
})
|
||||
|
||||
it('4. Diff generation for modified file', async () => {
|
||||
const testFile = 'test.txt'
|
||||
await writeFile(join(testDir, testFile), 'Hello FlowGit\nLine 2 added\n', 'utf8')
|
||||
|
||||
const diff = await getFileDiff(testDir, testFile)
|
||||
expect(diff.additions).toBe(1)
|
||||
expect(diff.deletions).toBe(0)
|
||||
expect(diff.hunks.length).toBeGreaterThan(0)
|
||||
expect(diff.hunks[0].lines.some(l => l.type === 'added' && l.content.includes('Line 2 added'))).toBe(true)
|
||||
})
|
||||
|
||||
it('5. Discards unstaged modifications', async () => {
|
||||
const testFile = 'test.txt'
|
||||
await discardFile(testDir, testFile)
|
||||
const status = await getRepoStatus(testDir)
|
||||
expect(status.unstaged.length).toBe(0)
|
||||
})
|
||||
|
||||
it('6. Branch creation, checkout, commit, and deletion', async () => {
|
||||
await createBranch(testDir, 'feature/blueprint-graph')
|
||||
let branches = await listBranches(testDir)
|
||||
expect(branches.some(b => b.name === 'feature/blueprint-graph')).toBe(true)
|
||||
|
||||
// Switch back to main
|
||||
await switchBranch(testDir, 'main')
|
||||
branches = await listBranches(testDir)
|
||||
const current = branches.find(b => b.isCurrent)
|
||||
expect(current?.name).toBe('main')
|
||||
|
||||
// Delete branch
|
||||
await deleteBranch(testDir, 'feature/blueprint-graph', true)
|
||||
branches = await listBranches(testDir)
|
||||
expect(branches.some(b => b.name === 'feature/blueprint-graph')).toBe(false)
|
||||
})
|
||||
|
||||
it('7. Stash operations: create, list, apply, drop', async () => {
|
||||
const testFile = 'test.txt'
|
||||
await writeFile(join(testDir, testFile), 'Stash test modifications\n', 'utf8')
|
||||
|
||||
await createStash(testDir, 'wip: test stash')
|
||||
let stashes = await listStash(testDir)
|
||||
expect(stashes.length).toBe(1)
|
||||
expect(stashes[0].message).toContain('wip: test stash')
|
||||
|
||||
// Apply stash
|
||||
const applyRes = await applyStash(testDir, 0)
|
||||
expect(applyRes.conflicts).toBe(false)
|
||||
|
||||
// Drop stash
|
||||
await dropStash(testDir, 0)
|
||||
stashes = await listStash(testDir)
|
||||
expect(stashes.length).toBe(0)
|
||||
|
||||
// Clean up modifications
|
||||
await discardFile(testDir, testFile)
|
||||
})
|
||||
|
||||
it('8. Tag creation, listing, and deletion', async () => {
|
||||
await createTag(testDir, 'v1.0.0', 'HEAD', 'Release version 1.0.0')
|
||||
let tags = await listTags(testDir)
|
||||
expect(tags.some(t => t.name === 'v1.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('9. Blueprint graph DAG computation', async () => {
|
||||
// Add second commit
|
||||
const docFile = 'README.md'
|
||||
await writeFile(join(testDir, docFile), '# FlowGit\n', 'utf8')
|
||||
await stageFile(testDir, docFile)
|
||||
await execGitOrThrow(['commit', '-m', 'docs: add readme'], { cwd: testDir })
|
||||
|
||||
const graph = await getGraphData(testDir, 100)
|
||||
expect(graph.nodes.length).toBe(2)
|
||||
expect(graph.edges.length).toBe(1)
|
||||
expect(graph.nodes[0].column).toBeDefined()
|
||||
expect(graph.nodes[0].row).toBe(0)
|
||||
expect(graph.nodes[1].row).toBe(1)
|
||||
})
|
||||
})
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@shared/*": ["shared/*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*", "shared/**/*"],
|
||||
"exclude": ["node_modules", "dist", "dist-electron", "electron"]
|
||||
}
|
||||
21
tsconfig.node.json
Normal file
21
tsconfig.node.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist-electron",
|
||||
"rootDir": ".",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@shared/*": ["shared/*"],
|
||||
"@electron/*": ["electron/*"]
|
||||
},
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["electron/**/*", "shared/**/*"],
|
||||
"exclude": ["node_modules", "dist", "dist-electron", "src"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user