diff --git a/GitWeb/src/main/ipc.ts b/GitWeb/src/main/ipc.ts index 167b172..136c902 100644 --- a/GitWeb/src/main/ipc.ts +++ b/GitWeb/src/main/ipc.ts @@ -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' @@ -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) => { diff --git a/GitWeb/src/main/publishRepository.ts b/GitWeb/src/main/publishRepository.ts index 0b8dc27..5f3c588 100644 --- a/GitWeb/src/main/publishRepository.ts +++ b/GitWeb/src/main/publishRepository.ts @@ -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' @@ -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 { + 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']) +} diff --git a/GitWeb/src/preload/index.d.ts b/GitWeb/src/preload/index.d.ts index 527ecb8..89373f8 100644 --- a/GitWeb/src/preload/index.d.ts +++ b/GitWeb/src/preload/index.d.ts @@ -68,6 +68,8 @@ export interface Api { isRepository: (cwd: string) => Promise scanForRepositories: (rootPath: string) => Promise init: (cwd: string) => Promise + /** 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 clone: (url: string, destination: string) => Promise cancelClone: (destination: string) => Promise onCloneProgress: (callback: (progress: CloneProgress) => void) => () => void diff --git a/GitWeb/src/preload/index.ts b/GitWeb/src/preload/index.ts index caf968c..6376027 100644 --- a/GitWeb/src/preload/index.ts +++ b/GitWeb/src/preload/index.ts @@ -92,6 +92,7 @@ const api = { scanForRepositories: (rootPath: string): Promise => invoke('git:scanForRepositories', rootPath), init: (cwd: string): Promise => invoke('git:init', cwd), + initialCommit: (cwd: string): Promise => invoke('git:initialCommit', cwd), clone: (url: string, destination: string): Promise => invoke('git:clone', url, destination), cancelClone: (destination: string): Promise => diff --git a/GitWeb/src/renderer/src/browserApiStub.ts b/GitWeb/src/renderer/src/browserApiStub.ts index 9d6c4dd..3baca58 100644 --- a/GitWeb/src/renderer/src/browserApiStub.ts +++ b/GitWeb/src/renderer/src/browserApiStub.ts @@ -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, diff --git a/GitWeb/src/renderer/src/components/CreateRepositoryDialog.tsx b/GitWeb/src/renderer/src/components/CreateRepositoryDialog.tsx new file mode 100644 index 0000000..b46cd6b --- /dev/null +++ b/GitWeb/src/renderer/src/components/CreateRepositoryDialog.tsx @@ -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(null) + const chooseRef = useRef(null) + const [path, setPath] = useState(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 { + 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( +
+
e.stopPropagation()} + > +
+ + + +
+

+ {t.createTitle} +

+

{t.createLead}

+
+
+ +

{t.folder}

+
+ + {path ?? t.noFolder} + + +
+ +
+ {options.map((option) => { + const Icon = option.icon + return ( + + ) + })} +
+ + {/* Cancel always last, on the far right. */} +
+ + +
+
+
, + document.body + ) +} diff --git a/GitWeb/src/renderer/src/components/TopBar.tsx b/GitWeb/src/renderer/src/components/TopBar.tsx index 90e7acb..0315b92 100644 --- a/GitWeb/src/renderer/src/components/TopBar.tsx +++ b/GitWeb/src/renderer/src/components/TopBar.tsx @@ -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 { diff --git a/GitWeb/src/renderer/src/store/repoStore.ts b/GitWeb/src/renderer/src/store/repoStore.ts index 6f6e30f..ba46d40 100644 --- a/GitWeb/src/renderer/src/store/repoStore.ts +++ b/GitWeb/src/renderer/src/store/repoStore.ts @@ -108,7 +108,10 @@ interface RepoState { addRecentRepositories: (paths: string[]) => Promise removeRecentRepo: (path: string) => Promise openRepository: (path: string) => Promise - createRepository: (path: string) => Promise + createRepository: (path: string, options?: { initialCommit?: boolean; publish?: boolean }) => Promise + /** 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 closeRepository: () => void @@ -463,16 +466,28 @@ export const useRepoStore = create((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 { diff --git a/GitWeb/src/renderer/src/views/AiChatView.tsx b/GitWeb/src/renderer/src/views/AiChatView.tsx index adfc7c9..3f2a071 100644 --- a/GitWeb/src/renderer/src/views/AiChatView.tsx +++ b/GitWeb/src/renderer/src/views/AiChatView.tsx @@ -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.
+ {/* Cancel always last, on the far right. */} + -
)} {uiCall.status === 'running' && ( diff --git a/GitWeb/src/renderer/src/views/InitialView.tsx b/GitWeb/src/renderer/src/views/InitialView.tsx index 95b1642..0b1befb 100644 --- a/GitWeb/src/renderer/src/views/InitialView.tsx +++ b/GitWeb/src/renderer/src/views/InitialView.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ComponentType } from 'react' +import CreateRepositoryDialog from '../components/CreateRepositoryDialog' import { AlertCircle, AlertTriangle, @@ -561,9 +562,9 @@ function InitialView(): JSX.Element { ) } - async function handleCreate(): Promise { - 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 }[] = [ @@ -610,6 +611,14 @@ function InitialView(): JSX.Element { setShowClone(true)} disabled={isBusy} active={showClone} /> + setCreateOpen(false)} + onCreate={(path, options) => { + setCreateOpen(false) + void createRepository(path, options) + }} + /> {hasGithubAccount === false && ( setLoginModalOpen(true)} disabled={isBusy} /> )} diff --git a/GitWeb/src/shared/i18n/messages/main.ts b/GitWeb/src/shared/i18n/messages/main.ts index d407cfa..c6345eb 100644 --- a/GitWeb/src/shared/i18n/messages/main.ts +++ b/GitWeb/src/shared/i18n/messages/main.ts @@ -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 "_".', @@ -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 "_".', @@ -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 "_".', diff --git a/GitWeb/src/shared/i18n/messages/publish.ts b/GitWeb/src/shared/i18n/messages/publish.ts index b82d898..71f8fdf 100644 --- a/GitWeb/src/shared/i18n/messages/publish.ts +++ b/GitWeb/src/shared/i18n/messages/publish.ts @@ -1,6 +1,16 @@ // "Publicar no GitHub": the dialog, the top-bar entry and the AI tool. const ptBR = { + createTitle: 'Criar repositório', + createLead: 'Um repositório git novo numa pasta do seu computador.', + folder: 'Pasta', + chooseFolder: 'Escolher pasta', + noFolder: 'Nenhuma pasta escolhida', + initialCommit: 'Fazer o primeiro commit', + initialCommitHint: 'Commita os arquivos da pasta, ou cria um README.md se ela estiver vazia.', + alsoPublish: 'Também criar no GitHub', + alsoPublishHint: 'Depois de criar, abre o "Publicar no GitHub" para criar o repositório lá e enviar.', + createAction: 'Criar', topBarLabel: 'Publicar no GitHub', topBarHint: 'Cria o repositório e envia', title: 'Publicar no GitHub', @@ -41,6 +51,16 @@ const ptBR = { type M = typeof ptBR const en: M = { + createTitle: 'Create repository', + createLead: 'A new git repository in a folder on your computer.', + folder: 'Folder', + chooseFolder: 'Choose folder', + noFolder: 'No folder chosen', + initialCommit: 'Make the first commit', + initialCommitHint: "Commits the folder's files, or creates a README.md if it is empty.", + alsoPublish: 'Also create on GitHub', + alsoPublishHint: 'After creating, opens "Publish to GitHub" to create the repository there and push.', + createAction: 'Create', topBarLabel: 'Publish to GitHub', topBarHint: 'Creates the repository and pushes', title: 'Publish to GitHub', @@ -79,6 +99,16 @@ const en: M = { } const es: M = { + createTitle: 'Crear repositorio', + createLead: 'Un repositorio git nuevo en una carpeta de tu equipo.', + folder: 'Carpeta', + chooseFolder: 'Elegir carpeta', + noFolder: 'Ninguna carpeta elegida', + initialCommit: 'Hacer el primer commit', + initialCommitHint: 'Hace commit de los archivos de la carpeta, o crea un README.md si está vacía.', + alsoPublish: 'También crear en GitHub', + alsoPublishHint: 'Después de crear, abre "Publicar en GitHub" para crear allí el repositorio y enviar.', + createAction: 'Crear', topBarLabel: 'Publicar en GitHub', topBarHint: 'Crea el repositorio y envía', title: 'Publicar en GitHub', diff --git a/GitWeb/test/publishRepository.test.ts b/GitWeb/test/publishRepository.test.ts index 83b9192..c986395 100644 --- a/GitWeb/test/publishRepository.test.ts +++ b/GitWeb/test/publishRepository.test.ts @@ -20,7 +20,7 @@ const createRepository = vi.fn(async (_host: string, input: { owner: string; nam })) vi.mock('../src/main/githubApi', () => ({ createRepository })) -const { publishRepository } = await import('../src/main/publishRepository') +const { createInitialCommit, publishRepository } = await import('../src/main/publishRepository') const { scanHistory } = await import('../src/main/sensitiveScan') function makeRepo(): { dir: string; git: (...args: string[]) => string } { @@ -100,3 +100,41 @@ describe('publishRepository', () => { } }) }) + +describe('createInitialCommit', () => { + beforeEach(() => { + settings.sensitiveCheckMode = 'block' + }) + + it('adds a README to an empty folder, commits the files of a non-empty one, and leaves an existing history alone', async () => { + const empty = makeRepo() + const withFiles = makeRepo() + try { + await createInitialCommit(empty.dir) + expect(empty.git('log', '--format=%s')).toBe('Commit inicial') + expect(empty.git('ls-files')).toBe('README.md') + + writeFileSync(join(withFiles.dir, 'index.ts'), `export {}\n`) + await createInitialCommit(withFiles.dir) + expect(withFiles.git('ls-files')).toBe('index.ts') + + writeFileSync(join(withFiles.dir, 'other.ts'), `export {}\n`) + await createInitialCommit(withFiles.dir) + expect(withFiles.git('rev-list', '--count', 'HEAD')).toBe('1') + } finally { + rmSync(empty.dir, { recursive: true, force: true }) + rmSync(withFiles.dir, { recursive: true, force: true }) + } + }) + + it('refuses a folder with a .env in block mode, leaving no commit', async () => { + const { dir, git } = makeRepo() + try { + writeFileSync(join(dir, '.env'), `TOKEN=x\n`) + await expect(createInitialCommit(dir)).rejects.toThrow('.env') + expect(() => git('rev-parse', '-q', '--verify', 'HEAD')).toThrow() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +})