diff --git a/src/main/git-service.ts b/src/main/git-service.ts index 705e474..1c7c076 100644 --- a/src/main/git-service.ts +++ b/src/main/git-service.ts @@ -243,6 +243,24 @@ export async function commit(root: string, message: string): Promise { await git(root, ['commit', '-m', message]) } +/** + * Push the current branch to its remote. If the branch has no upstream yet, + * retry with `-u origin ` so the first push also sets tracking. + * Returns a concise one-line summary for the toast (git writes progress to + * stderr, so we pull the summary from there). + */ +export async function push(root: string): Promise<{ ok: boolean; message: string }> { + let res = await runGit(root, ['push']) + if (res.code !== 0 && /no upstream branch|set-upstream/i.test(res.stderr)) { + const branch = (await runGit(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() + if (branch && branch !== 'HEAD') res = await runGit(root, ['push', '-u', 'origin', branch]) + } + const lines = (res.stderr || res.stdout).trim().split('\n').map((l) => l.trim()).filter(Boolean) + if (res.code !== 0) return { ok: false, message: lines.pop() || 'push failed' } + const summary = lines.find((l) => /->|up-to-date|new branch/i.test(l)) || lines.pop() || 'Pushed' + return { ok: true, message: summary } +} + /** * Discard working-tree changes for each path: * - exists in HEAD → restore index + worktree to the last commit diff --git a/src/main/index.ts b/src/main/index.ts index 360bf83..9898933 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,7 +5,7 @@ import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron' import { watch, type FSWatcher } from 'chokidar' import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project' import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readProjectFile, readTree, writeProjectFile } from './fs-service' -import { commit, discard, load, stage, unstage } from './git-service' +import { commit, discard, load, push, stage, unstage } from './git-service' import { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service' import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config' import { listFiles, searchContent } from './search-service' @@ -215,6 +215,7 @@ function registerIpc(): void { ipcMain.handle('git:stage', (_e, paths: string[]) => { const r = getRoot(); if (r) return stage(r, paths) }) ipcMain.handle('git:unstage', (_e, paths: string[]) => { const r = getRoot(); if (r) return unstage(r, paths) }) ipcMain.handle('git:commit', (_e, message: string) => { const r = getRoot(); if (r) return commit(r, message) }) + ipcMain.handle('git:push', () => { const r = getRoot(); return r ? push(r) : { ok: false, message: 'No project open' } }) ipcMain.handle('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, paths) }) ipcMain.handle('pty:available', () => ptyAvailable()) diff --git a/src/preload/index.ts b/src/preload/index.ts index faffe9e..748ff1b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -40,6 +40,7 @@ const api = { stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths), unstage: (paths: string[]) => ipcRenderer.invoke('git:unstage', paths), commit: (message: string) => ipcRenderer.invoke('git:commit', message), + push: (): Promise<{ ok: boolean; message: string }> => ipcRenderer.invoke('git:push'), discard: (paths: string[]) => ipcRenderer.invoke('git:discard', paths), }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9575a4d..347de98 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -240,17 +240,27 @@ export function App(): React.ReactElement { if (activePanel === 'tree') document.querySelector('.tree-row.kbd')?.scrollIntoView({ block: 'nearest' }) }, [treeSel, activePanel]) - // Flash the branch · repository banner for ~2s each time the window gains focus. + // Flash the branch · repository banner for ~2s each time the window *regains* + // focus. We gate on a prior blur so the banner never shows on startup (or on + // any focus event fired during launch) — only on a genuine "welcome back". useEffect(() => { let timer: ReturnType + let wasBlurred = false function flash(): void { + if (!wasBlurred) return // first-time-after-open (or launch focus): skip + wasBlurred = false setShowFlash(true) clearTimeout(timer) timer = setTimeout(() => setShowFlash(false), 2000) } - if (document.hasFocus()) flash() + function onBlur(): void { wasBlurred = true } window.addEventListener('focus', flash) - return () => { window.removeEventListener('focus', flash); clearTimeout(timer) } + window.addEventListener('blur', onBlur) + return () => { + window.removeEventListener('focus', flash) + window.removeEventListener('blur', onBlur) + clearTimeout(timer) + } }, []) // Open/reopen a project with a fully collapsed tree: seed the expansion set @@ -330,6 +340,13 @@ export function App(): React.ReactElement { setCommitMsg('') } + function push(): void { + toast('Pushing…') + actions.push() + .then((r) => toast(r.ok ? 'Pushed' : 'Push failed', r.message)) + .catch((e) => toast('Push failed', String(e?.message ?? e))) + } + function revealInFinder(path: string): void { window.helder?.shell.reveal(path) } @@ -675,9 +692,11 @@ export function App(): React.ReactElement { setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0) setOverlay('history') } - // ⌘F and ⌘P both open the unified search (it covers file names too), seeded - // with the current selection when there is one. - else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') } + // ⌘F opens the unified search (it covers file names too), seeded with the + // current selection when there is one. + else if (meta && e.key.toLowerCase() === 'f') { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') } + // ⌘P pushes the current branch to its remote. + else if (meta && e.key.toLowerCase() === 'p') { e.preventDefault(); push() } else if (meta && e.key.toLowerCase() === 's') { e.preventDefault(); saveActive() } else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) } // ⌘D deletes the current file (with confirmation). @@ -774,7 +793,7 @@ export function App(): React.ReactElement { onMouseDownCapture={() => setActivePanel('git')}> 300} /> diff --git a/src/renderer/src/components.tsx b/src/renderer/src/components.tsx index fbe5e8c..854fcee 100644 --- a/src/renderer/src/components.tsx +++ b/src/renderer/src/components.tsx @@ -27,6 +27,7 @@ export const Icon: Record React.ReactElement> = { trash: (p) => (), finder: (p) => (), eye: (p) => (), + push: (p) => (), } export const Chevron = ({ open }: { open: boolean }): React.ReactElement => ( @@ -97,7 +98,7 @@ function GitRow({ c, staged, activePath, ctxPath, kbdPath, showDir, onOpen, onCo ) } -export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onOpen, onContext, activePath, ctxPath, kbdPath, showDir }: { +export function GitPanel({ branch, changes, staged, committed, commitMsg, setCommitMsg, onStage, onUnstage, onStageAll, onUnstageAll, onCommit, onPush, onOpen, onContext, activePath, ctxPath, kbdPath, showDir }: { branch: string changes: Change[] staged: Set @@ -109,6 +110,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom onStageAll: () => void onUnstageAll: () => void onCommit: () => void + onPush: () => void onOpen: OpenFile onContext: OnContext activePath: string | null @@ -129,6 +131,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom placeholder="Shift+Enter to commit" onChange={(e) => setCommitMsg(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} /> +
diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 222b87d..342fae5 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -36,6 +36,7 @@ interface HelderBridge { stage: (paths: string[]) => Promise unstage: (paths: string[]) => Promise commit: (message: string) => Promise + push: () => Promise<{ ok: boolean; message: string }> discard: (paths: string[]) => Promise } pty: { diff --git a/src/renderer/src/overlays.tsx b/src/renderer/src/overlays.tsx index 12762f7..a9a1ee6 100644 --- a/src/renderer/src/overlays.tsx +++ b/src/renderer/src/overlays.tsx @@ -361,6 +361,7 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [ { keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' }, { keys: ['⌘', 'C'], label: 'Focus the commit message' }, { keys: ['⌘', '↵'], label: 'Commit the staged files' }, + { keys: ['⌘', 'P'], label: 'Push the current branch to its remote' }, { keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' }, { keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' }, { keys: ['⌘', 'S'], label: 'Save the current file' }, diff --git a/src/renderer/src/project.tsx b/src/renderer/src/project.tsx index 8003927..639650b 100644 --- a/src/renderer/src/project.tsx +++ b/src/renderer/src/project.tsx @@ -60,6 +60,7 @@ export interface ProjectActions { stageAll: () => void unstageAll: () => void commit: (message: string) => Promise + push: () => Promise<{ ok: boolean; message: string }> discard: (path: string) => void ensureFile: (path: string) => void /** Force re-read a file from disk into the content index, returning the fresh @@ -216,6 +217,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re setData((d) => ({ ...d, changes: d.changes.filter((c) => !d.staged.has(c.path)), staged: new Set() })) return n }, + push: async () => ({ ok: true, message: 'Pushed (preview)' }), discard: (p) => setData((d) => ({ ...d, changes: d.changes.filter((c) => c.path !== p), @@ -255,6 +257,11 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re await loadGit() return n }, + push: async () => { + const r = await bridge.git.push() + await loadGit() + return r + }, discard: (p) => after(bridge.git.discard([p])), ensureFile: (path) => { if (dataRef.current.files[path] != null) return diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index b962c62..61045c5 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -149,6 +149,8 @@ body { .commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; } .commit-input:focus { border-color:var(--accent-line); } .commit-input::placeholder { color:var(--fg-3); } +.push-btn { flex:0 0 auto; display:flex; align-items:center; justify-content:center; width:32px; min-height:32px; align-self:stretch; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-2); cursor:pointer; } +.push-btn:hover { background:var(--hover); border-color:var(--accent-line); color:var(--accent); } .commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#201608; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; } .commit-btn:hover:not(:disabled) { background:#f6b35f; } .commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }