Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion GitWeb/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ import {
} from './recentRepositories'
import { scanForRepositories } from './repoScan'
import { assertNoBlockedSensitive, scanHistory, scanSensitive, type SensitiveScanScope } from './sensitiveScan'
import { publishRepository } from './publishRepository'
import { createInitialCommit, publishRepository } from './publishRepository'
import { assertAgentMayRead, redactForAi } from './aiRedaction'
import { sensitiveFileKind } from '../shared/sensitiveFiles'
import { getAiSettings, getSettings, setAiSettings, setAppSettings, setLastRepositoryPath } from './settings'
Expand Down Expand Up @@ -166,6 +166,7 @@ export function registerIpcHandlers(): void {
handle('git:isRepository', (_e, cwd: string) => isGitRepository(cwd))
handle('git:scanForRepositories', (_e, rootPath: string) => scanForRepositories(rootPath))
handle('git:init', (_e, cwd: string) => initRepository(cwd))
handle('git:initialCommit', (_e, cwd: string) => createInitialCommit(cwd))
// Uses the event so progress goes only to the frame that asked for the clone.
handle('git:clone', (event, url: string, destination: string) =>
cloneRepository(url, destination, (progress) => {
Expand Down
18 changes: 18 additions & 0 deletions GitWeb/src/main/publishRepository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { readdir, writeFile } from 'fs/promises'
import { basename, join } from 'path'
import { getRemoteUrl, pushBranch, readGit, runGit, throwOnFailure } from './gitProcess'
import { assertNoBlockedSensitive } from './sensitiveScan'
import { createRepository } from './githubApi'
import { getSettings } from './settings'
import { scanHistory } from './sensitiveScan'
Expand Down Expand Up @@ -44,3 +47,18 @@ export async function publishRepository(cwd: string, input: PublishRepositoryInp
}
return { fullName: repo.fullName, htmlUrl: repo.htmlUrl, branch }
}

/**
* The first commit of a repository just created, so it has something to
* publish: the folder's files, or a README.md when the folder is empty (what
* GitHub's "Add a README" does). Skipped when HEAD already exists. Goes
* through the same sensitive-file guard as every other commit.
*/
export async function createInitialCommit(cwd: string): Promise<void> {
if ((await readGit(['rev-parse', '-q', '--verify', 'HEAD'], cwd)).exitCode === 0) return
const entries = (await readdir(cwd)).filter((name) => name !== '.git')
if (entries.length === 0) await writeFile(join(cwd, 'README.md'), `# ${basename(cwd)}\n`, 'utf8')
throwOnFailure(await runGit(['add', '-A'], cwd), ['add', '-A'])
await assertNoBlockedSensitive(cwd)
throwOnFailure(await runGit(['commit', '-m', tMain().publish.initialCommitMessage], cwd), ['commit'])
}
2 changes: 2 additions & 0 deletions GitWeb/src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export interface Api {
isRepository: (cwd: string) => Promise<boolean>
scanForRepositories: (rootPath: string) => Promise<string[]>
init: (cwd: string) => Promise<void>
/** First commit of a new repo: its files, or a README.md if the folder is empty. No-op once HEAD exists. */
initialCommit: (cwd: string) => Promise<void>
clone: (url: string, destination: string) => Promise<CloneOutcome>
cancelClone: (destination: string) => Promise<void>
onCloneProgress: (callback: (progress: CloneProgress) => void) => () => void
Expand Down
1 change: 1 addition & 0 deletions GitWeb/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const api = {
scanForRepositories: (rootPath: string): Promise<string[]> =>
invoke('git:scanForRepositories', rootPath),
init: (cwd: string): Promise<void> => invoke('git:init', cwd),
initialCommit: (cwd: string): Promise<void> => invoke('git:initialCommit', cwd),
clone: (url: string, destination: string): Promise<CloneOutcome> =>
invoke('git:clone', url, destination),
cancelClone: (destination: string): Promise<void> =>
Expand Down
1 change: 1 addition & 0 deletions GitWeb/src/renderer/src/browserApiStub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ function installBrowserApiStub(): void {
isRepository: async () => true,
scanForRepositories: async () => [],
init: async () => undefined,
initialCommit: async () => undefined,
clone: async () => ({ status: 'ok' as const }),
cancelClone: async () => undefined,
onCloneProgress: () => () => undefined,
Expand Down
148 changes: 148 additions & 0 deletions GitWeb/src/renderer/src/components/CreateRepositoryDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { FolderOpen, FolderPlus } from 'lucide-react'
import Github from './icons/Github'
import { useFocusTrap } from '../lib/useFocusTrap'
import { useT } from '../lib/i18n'

/**
* "Create" on the start screen: a folder, the first commit (on by default —
* there is nothing to publish without one) and, optionally, publishing to
* GitHub right after, which opens the publish dialog once the repo is open.
*/
export default function CreateRepositoryDialog({
open,
onClose,
onCreate
}: {
open: boolean
onClose: () => void
onCreate: (path: string, options: { initialCommit: boolean; publish: boolean }) => void
}): JSX.Element | null {
const t = useT().publish
const common = useT().common
const titleId = useId()
const commitId = useId()
const publishId = useId()
const panelRef = useRef<HTMLDivElement>(null)
const chooseRef = useRef<HTMLButtonElement>(null)
const [path, setPath] = useState<string | null>(null)
const [initialCommit, setInitialCommit] = useState(true)
const [publish, setPublish] = useState(false)

useEffect(() => {
if (!open) return
setPath(null)
setInitialCommit(true)
setPublish(false)
}, [open])

useFocusTrap(panelRef, open, { onEscape: onClose, initialFocusRef: chooseRef })

if (!open) return null

async function choose(): Promise<void> {
const picked = await window.api.dialogs.openDirectory()
if (picked) setPath(picked)
}

const options = [
{
id: commitId,
icon: FolderPlus,
label: t.initialCommit,
hint: t.initialCommitHint,
// Publishing needs a commit, so it holds this one on.
checked: initialCommit || publish,
disabled: publish,
onChange: setInitialCommit
},
{ id: publishId, icon: Github, label: t.alsoPublish, hint: t.alsoPublishHint, checked: publish, disabled: false, onChange: setPublish }
]

return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center bg-modal-scrim p-4" role="presentation" onClick={onClose}>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="w-full min-w-0 max-w-md rounded-lg border border-default-border bg-obsidian-panel p-5 shadow-floating"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-command-blue/30 bg-command-blue-soft text-command-blue">
<FolderPlus size={18} aria-hidden />
</span>
<div className="min-w-0">
<h2 id={titleId} className="text-title font-semibold text-primary-text">
{t.createTitle}
</h2>
<p className="mt-1 text-body text-secondary-text">{t.createLead}</p>
</div>
</div>

<p className="mt-4 text-caption text-secondary-text">{t.folder}</p>
<div className="mt-1.5 flex min-w-0 items-center gap-2">
<span
className={`min-w-0 flex-1 truncate rounded-md border border-default-border bg-midnight-base px-2.5 py-1.5 font-mono text-caption ${
path ? 'text-primary-text' : 'text-tertiary-text'
}`}
title={path ?? undefined}
>
{path ?? t.noFolder}
</span>
<button ref={chooseRef} type="button" onClick={() => void choose()} className="btn-secondary shrink-0">
<FolderOpen size={14} aria-hidden /> {t.chooseFolder}
</button>
</div>

<div className="mt-4 flex flex-col gap-2">
{options.map((option) => {
const Icon = option.icon
return (
<label
key={option.id}
htmlFor={option.id}
className={`flex items-start gap-3 rounded-md border border-default-border bg-coal-raised px-3 py-2.5 ${
option.disabled ? 'opacity-70' : 'cursor-pointer hover:border-primary-text/20'
}`}
>
<input
id={option.id}
type="checkbox"
checked={option.checked}
disabled={option.disabled}
onChange={(e) => option.onChange(e.target.checked)}
className="file-check mt-0.5"
/>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 text-body font-semibold text-primary-text">
<Icon size={14} className="text-secondary-text" aria-hidden /> {option.label}
</span>
<span className="mt-0.5 block text-caption text-secondary-text">{option.hint}</span>
</span>
</label>
)
})}
</div>

{/* Cancel always last, on the far right. */}
<div className="mt-5 flex justify-end gap-2 border-t border-default-border pt-4">
<button
type="button"
disabled={!path}
onClick={() => path && onCreate(path, { initialCommit: initialCommit || publish, publish })}
className="btn-primary"
>
{t.createAction}
</button>
<button type="button" onClick={onClose} className="btn-secondary">
{common.cancel}
</button>
</div>
</div>
</div>,
document.body
)
}
8 changes: 8 additions & 0 deletions GitWeb/src/renderer/src/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,14 @@ function SyncCard({ compact }: { compact: boolean }): JSX.Element {
if (noUpstream) void checkOrigin()
}, [noUpstream, checkOrigin])
const needsRepository = noUpstream && !hasOrigin
// "Create and publish" from the start screen lands here with the repo open.
const publishPrompt = useRepoStore((s) => s.publishPrompt)
const clearPublishPrompt = useRepoStore((s) => s.clearPublishPrompt)
useEffect(() => {
if (!publishPrompt || !currentRepoPath) return
clearPublishPrompt()
setPublishOpen(true)
}, [publishPrompt, currentRepoPath, clearPublishPrompt])
// Re-checked on click and when the dialog closes: an origin added outside
// the app, or by a publish whose push failed, turns this into "Publish branch".
async function onActivate(): Promise<void> {
Expand Down
19 changes: 17 additions & 2 deletions GitWeb/src/renderer/src/store/repoStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ interface RepoState {
addRecentRepositories: (paths: string[]) => Promise<void>
removeRecentRepo: (path: string) => Promise<void>
openRepository: (path: string) => Promise<void>
createRepository: (path: string) => Promise<void>
createRepository: (path: string, options?: { initialCommit?: boolean; publish?: boolean }) => Promise<void>
/** Set by "create and publish": the top bar opens the publish dialog once the repo is open. */
publishPrompt: boolean
clearPublishPrompt: () => void
cloneRepository: (url: string, destination: string) => Promise<'ok' | 'cancelled' | 'failed'>
cancelClone: (destination: string) => Promise<void>
closeRepository: () => void
Expand Down Expand Up @@ -463,16 +466,28 @@ export const useRepoStore = create<RepoState>((set, get) => ({
}
},

createRepository: async (path: string) => {
createRepository: async (path, options = {}) => {
set({ isBusy: true, error: null })
try {
await window.api.git.init(path)
// Publishing needs a commit, so it implies the first one.
if (options.initialCommit || options.publish) await window.api.git.initialCommit(path)
await get().openRepository(path)
if (options.publish) set({ publishPrompt: true })
} catch (err) {
set({ error: describeError(err), isBusy: false })
// The repository exists even if the first commit was refused (a
// sensitive file in block mode): open it so the user can fix that.
if (get().currentRepoPath !== path)
await get()
.openRepository(path)
.catch(() => undefined)
}
},

publishPrompt: false,
clearPublishPrompt: () => set({ publishPrompt: false }),

cloneRepository: async (url: string, destination: string) => {
set({ isBusy: true, error: null })
try {
Expand Down
7 changes: 4 additions & 3 deletions GitWeb/src/renderer/src/views/AiChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,16 @@ function ToolCallCard({ messageId, uiCall }: { messageId: string; uiCall: import
// Compact on the right when the card is wide; in a narrow panel they
// share the row, and stack full-width once their labels no longer fit.
<div className="mt-3 flex flex-wrap justify-end gap-2 border-t border-default-border pt-3">
{/* Cancel always last, on the far right. */}
<button onClick={() => approveToolCall(messageId, uiCall.call.id)} className={`${approveClass} flex-auto @[22rem]:flex-none`}>
<Check size={13} /> {approveLabel}
</button>
<button
onClick={() => cancelToolCall(messageId, uiCall.call.id)}
className="btn-secondary h-8 flex-auto px-3 @[22rem]:flex-none"
>
<X size={13} /> {ui.toolCancel}
</button>
<button onClick={() => approveToolCall(messageId, uiCall.call.id)} className={`${approveClass} flex-auto @[22rem]:flex-none`}>
<Check size={13} /> {approveLabel}
</button>
</div>
)}
{uiCall.status === 'running' && (
Expand Down
15 changes: 12 additions & 3 deletions GitWeb/src/renderer/src/views/InitialView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ComponentType } from 'react'
import CreateRepositoryDialog from '../components/CreateRepositoryDialog'
import {
AlertCircle,
AlertTriangle,
Expand Down Expand Up @@ -561,9 +562,9 @@ function InitialView(): JSX.Element {
)
}

async function handleCreate(): Promise<void> {
const path = await window.api.dialogs.openDirectory()
if (path) await createRepository(path)
const [createOpen, setCreateOpen] = useState(false)
function handleCreate(): void {
setCreateOpen(true)
}

const summaryParts: { tone: string; text: string }[] = [
Expand Down Expand Up @@ -610,6 +611,14 @@ function InitialView(): JSX.Element {
</button>
<RailAction icon={DownloadCloud} label={t.clone} description={t.cloneHint} onClick={() => setShowClone(true)} disabled={isBusy} active={showClone} />
<RailAction icon={FolderPlus} label={t.create} description={t.createHint} onClick={handleCreate} disabled={isBusy} />
<CreateRepositoryDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onCreate={(path, options) => {
setCreateOpen(false)
void createRepository(path, options)
}}
/>
{hasGithubAccount === false && (
<RailAction icon={Github} label={t.signInGithub} description={t.signInGithubHint} onClick={() => setLoginModalOpen(true)} disabled={isBusy} />
)}
Expand Down
3 changes: 3 additions & 0 deletions GitWeb/src/shared/i18n/messages/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ const ptBR = {
},
publish: {
hasRemote: 'Este repositório já tem um remote (origin). Use "Publicar branch" para enviá-lo.',
initialCommitMessage: 'Commit inicial',
noCommits: 'Faça o primeiro commit antes de publicar: um repositório sem commits não tem o que enviar.',
noBranch: 'Não há branch em uso (HEAD destacado). Troque para uma branch antes de publicar.',
invalidName: 'Nome de repositório inválido. Use letras, números, ".", "-" ou "_".',
Expand Down Expand Up @@ -276,6 +277,7 @@ const en: MainMessages = {
},
publish: {
hasRemote: 'This repository already has a remote (origin). Use "Publish branch" to push it.',
initialCommitMessage: 'Initial commit',
noCommits: 'Make the first commit before publishing: a repository with no commits has nothing to push.',
noBranch: 'No branch is checked out (detached HEAD). Switch to a branch before publishing.',
invalidName: 'Invalid repository name. Use letters, digits, ".", "-" or "_".',
Expand Down Expand Up @@ -427,6 +429,7 @@ const es: MainMessages = {
},
publish: {
hasRemote: 'Este repositorio ya tiene un remoto (origin). Usa "Publicar rama" para enviarlo.',
initialCommitMessage: 'Commit inicial',
noCommits: 'Haz el primer commit antes de publicar: un repositorio sin commits no tiene nada que enviar.',
noBranch: 'No hay rama en uso (HEAD separado). Cambia a una rama antes de publicar.',
invalidName: 'Nombre de repositorio no válido. Usa letras, números, ".", "-" o "_".',
Expand Down
Loading
Loading