faster loading
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-06-23 08:52:05 +02:00
parent 73bfd2b86a
commit 43131915c0
9 changed files with 62 additions and 9 deletions

View File

@@ -243,6 +243,24 @@ export async function commit(root: string, message: string): Promise<void> {
await git(root, ['commit', '-m', message])
}
/**
* Push the current branch to its remote. If the branch has no upstream yet,
* retry with `-u origin <branch>` 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

View File

@@ -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())

View File

@@ -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),
},

View File

@@ -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<typeof setTimeout>
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')}>
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit}
onStage={stageGuarded} onUnstage={unstageGuarded} onStageAll={actions.stageAll} onUnstageAll={actions.unstageAll} onCommit={commit} onPush={push}
onOpen={openFile} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
kbdPath={activePanel === 'git' ? gitSelPath : null} showDir={gitW > 300} />
</div>

View File

@@ -27,6 +27,7 @@ export const Icon: Record<string, (p?: SvgProps) => React.ReactElement> = {
trash: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
finder: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><rect x="2" y="3.5" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" /><path d="M9 7l3-3M12 4v2.6M12 4H9.4" stroke="currentColor" strokeWidth="1.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
eye: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z" stroke="currentColor" strokeWidth="1.2" fill="none" /><circle cx="8" cy="8" r="2" stroke="currentColor" strokeWidth="1.2" /></svg>),
push: (p) => (<svg width="13" height="13" viewBox="0 0 16 16" fill="none" {...p}><path d="M8 13V4M8 4 4.5 7.5M8 4l3.5 3.5M3.5 2.5h9" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>),
}
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<string>
@@ -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() } }} />
<button className="push-btn" title="Push to remote (⌘P)" onClick={onPush}>{Icon.push()}</button>
</div>
<div className="git-body">

View File

@@ -36,6 +36,7 @@ interface HelderBridge {
stage: (paths: string[]) => Promise<void>
unstage: (paths: string[]) => Promise<void>
commit: (message: string) => Promise<void>
push: () => Promise<{ ok: boolean; message: string }>
discard: (paths: string[]) => Promise<void>
}
pty: {

View File

@@ -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' },

View File

@@ -60,6 +60,7 @@ export interface ProjectActions {
stageAll: () => void
unstageAll: () => void
commit: (message: string) => Promise<number>
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

View File

@@ -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; }