@@ -243,6 +243,24 @@ export async function commit(root: string, message: string): Promise<void> {
|
|||||||
await git(root, ['commit', '-m', message])
|
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:
|
* Discard working-tree changes for each path:
|
||||||
* - exists in HEAD → restore index + worktree to the last commit
|
* - exists in HEAD → restore index + worktree to the last commit
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { app, dialog, shell, BrowserWindow, ipcMain, Menu } from 'electron'
|
|||||||
import { watch, type FSWatcher } from 'chokidar'
|
import { watch, type FSWatcher } from 'chokidar'
|
||||||
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
|
import { addRecentProject, getName, getRecentProjects, getRoot, openDialog, setRoot } from './project'
|
||||||
import { createProjectDir, createProjectFile, deleteProjectFile, readAll, readDirChildren, readProjectFile, readTree, writeProjectFile } from './fs-service'
|
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 { createPty, killAllPtys, killPty, ptyAvailable, resizePty, writePty } from './pty-service'
|
||||||
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
import { getConfig, getRecent, getThemeCss, resolveConfig, setRecent } from './config'
|
||||||
import { listFiles, searchContent } from './search-service'
|
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: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: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: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('git:discard', (_e, paths: string[]) => { const r = getRoot(); if (r) return discard(r, paths) })
|
||||||
|
|
||||||
ipcMain.handle('pty:available', () => ptyAvailable())
|
ipcMain.handle('pty:available', () => ptyAvailable())
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const api = {
|
|||||||
stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths),
|
stage: (paths: string[]) => ipcRenderer.invoke('git:stage', paths),
|
||||||
unstage: (paths: string[]) => ipcRenderer.invoke('git:unstage', paths),
|
unstage: (paths: string[]) => ipcRenderer.invoke('git:unstage', paths),
|
||||||
commit: (message: string) => ipcRenderer.invoke('git:commit', message),
|
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),
|
discard: (paths: string[]) => ipcRenderer.invoke('git:discard', paths),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -240,17 +240,27 @@ export function App(): React.ReactElement {
|
|||||||
if (activePanel === 'tree') document.querySelector('.tree-row.kbd')?.scrollIntoView({ block: 'nearest' })
|
if (activePanel === 'tree') document.querySelector('.tree-row.kbd')?.scrollIntoView({ block: 'nearest' })
|
||||||
}, [treeSel, activePanel])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let timer: ReturnType<typeof setTimeout>
|
let timer: ReturnType<typeof setTimeout>
|
||||||
|
let wasBlurred = false
|
||||||
function flash(): void {
|
function flash(): void {
|
||||||
|
if (!wasBlurred) return // first-time-after-open (or launch focus): skip
|
||||||
|
wasBlurred = false
|
||||||
setShowFlash(true)
|
setShowFlash(true)
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
timer = setTimeout(() => setShowFlash(false), 2000)
|
timer = setTimeout(() => setShowFlash(false), 2000)
|
||||||
}
|
}
|
||||||
if (document.hasFocus()) flash()
|
function onBlur(): void { wasBlurred = true }
|
||||||
window.addEventListener('focus', flash)
|
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
|
// Open/reopen a project with a fully collapsed tree: seed the expansion set
|
||||||
@@ -330,6 +340,13 @@ export function App(): React.ReactElement {
|
|||||||
setCommitMsg('')
|
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 {
|
function revealInFinder(path: string): void {
|
||||||
window.helder?.shell.reveal(path)
|
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)
|
setHistInitSel(e.key === 'ArrowDown' ? Math.min(1, history.length - 1) : 0)
|
||||||
setOverlay('history')
|
setOverlay('history')
|
||||||
}
|
}
|
||||||
// ⌘F and ⌘P both open the unified search (it covers file names too), seeded
|
// ⌘F opens the unified search (it covers file names too), seeded with the
|
||||||
// with the current selection when there is one.
|
// current selection when there is one.
|
||||||
else if (meta && (e.key.toLowerCase() === 'f' || e.key.toLowerCase() === 'p')) { e.preventDefault(); setSearchInit(selectedSearchText()); setOverlay('search') }
|
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() === 's') { e.preventDefault(); saveActive() }
|
||||||
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
|
else if (meta && e.key.toLowerCase() === 'w') { e.preventDefault(); if (active) closeTab(active) }
|
||||||
// ⌘D deletes the current file (with confirmation).
|
// ⌘D deletes the current file (with confirmation).
|
||||||
@@ -774,7 +793,7 @@ export function App(): React.ReactElement {
|
|||||||
onMouseDownCapture={() => setActivePanel('git')}>
|
onMouseDownCapture={() => setActivePanel('git')}>
|
||||||
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
|
<GitPanel branch={proj.branch} changes={proj.changes} staged={proj.staged} committed={NO_COMMITTED}
|
||||||
commitMsg={commitMsg} setCommitMsg={setCommitMsg}
|
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}
|
onOpen={openFile} onContext={openMenu} activePath={active} ctxPath={menu?.path ?? null}
|
||||||
kbdPath={activePanel === 'git' ? gitSelPath : null} showDir={gitW > 300} />
|
kbdPath={activePanel === 'git' ? gitSelPath : null} showDir={gitW > 300} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>),
|
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>),
|
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>),
|
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 => (
|
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
|
branch: string
|
||||||
changes: Change[]
|
changes: Change[]
|
||||||
staged: Set<string>
|
staged: Set<string>
|
||||||
@@ -109,6 +110,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
|
|||||||
onStageAll: () => void
|
onStageAll: () => void
|
||||||
onUnstageAll: () => void
|
onUnstageAll: () => void
|
||||||
onCommit: () => void
|
onCommit: () => void
|
||||||
|
onPush: () => void
|
||||||
onOpen: OpenFile
|
onOpen: OpenFile
|
||||||
onContext: OnContext
|
onContext: OnContext
|
||||||
activePath: string | null
|
activePath: string | null
|
||||||
@@ -129,6 +131,7 @@ export function GitPanel({ branch, changes, staged, committed, commitMsg, setCom
|
|||||||
placeholder="Shift+Enter to commit"
|
placeholder="Shift+Enter to commit"
|
||||||
onChange={(e) => setCommitMsg(e.target.value)}
|
onChange={(e) => setCommitMsg(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.shiftKey || e.metaKey || e.ctrlKey) && canCommit) { e.preventDefault(); onCommit() } }} />
|
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>
|
||||||
|
|
||||||
<div className="git-body">
|
<div className="git-body">
|
||||||
|
|||||||
1
src/renderer/src/env.d.ts
vendored
1
src/renderer/src/env.d.ts
vendored
@@ -36,6 +36,7 @@ interface HelderBridge {
|
|||||||
stage: (paths: string[]) => Promise<void>
|
stage: (paths: string[]) => Promise<void>
|
||||||
unstage: (paths: string[]) => Promise<void>
|
unstage: (paths: string[]) => Promise<void>
|
||||||
commit: (message: string) => Promise<void>
|
commit: (message: string) => Promise<void>
|
||||||
|
push: () => Promise<{ ok: boolean; message: string }>
|
||||||
discard: (paths: string[]) => Promise<void>
|
discard: (paths: string[]) => Promise<void>
|
||||||
}
|
}
|
||||||
pty: {
|
pty: {
|
||||||
|
|||||||
@@ -361,6 +361,7 @@ const SHORTCUTS: { keys: string[]; label: string }[] = [
|
|||||||
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
|
{ keys: ['⌘', 'M'], label: 'Cycle view: Updated · Original · Diff · Split' },
|
||||||
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
{ keys: ['⌘', 'C'], label: 'Focus the commit message' },
|
||||||
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
|
{ keys: ['⌘', '↵'], label: 'Commit the staged files' },
|
||||||
|
{ keys: ['⌘', 'P'], label: 'Push the current branch to its remote' },
|
||||||
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
|
{ keys: ['⌘', 'A'], label: 'Toggle auto-fit panels' },
|
||||||
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
|
{ keys: ['⌘', '.'], label: 'Toggle hidden (dot)files' },
|
||||||
{ keys: ['⌘', 'S'], label: 'Save the current file' },
|
{ keys: ['⌘', 'S'], label: 'Save the current file' },
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export interface ProjectActions {
|
|||||||
stageAll: () => void
|
stageAll: () => void
|
||||||
unstageAll: () => void
|
unstageAll: () => void
|
||||||
commit: (message: string) => Promise<number>
|
commit: (message: string) => Promise<number>
|
||||||
|
push: () => Promise<{ ok: boolean; message: string }>
|
||||||
discard: (path: string) => void
|
discard: (path: string) => void
|
||||||
ensureFile: (path: string) => void
|
ensureFile: (path: string) => void
|
||||||
/** Force re-read a file from disk into the content index, returning the fresh
|
/** 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() }))
|
setData((d) => ({ ...d, changes: d.changes.filter((c) => !d.staged.has(c.path)), staged: new Set() }))
|
||||||
return n
|
return n
|
||||||
},
|
},
|
||||||
|
push: async () => ({ ok: true, message: 'Pushed (preview)' }),
|
||||||
discard: (p) => setData((d) => ({
|
discard: (p) => setData((d) => ({
|
||||||
...d,
|
...d,
|
||||||
changes: d.changes.filter((c) => c.path !== p),
|
changes: d.changes.filter((c) => c.path !== p),
|
||||||
@@ -255,6 +257,11 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
|
|||||||
await loadGit()
|
await loadGit()
|
||||||
return n
|
return n
|
||||||
},
|
},
|
||||||
|
push: async () => {
|
||||||
|
const r = await bridge.git.push()
|
||||||
|
await loadGit()
|
||||||
|
return r
|
||||||
|
},
|
||||||
discard: (p) => after(bridge.git.discard([p])),
|
discard: (p) => after(bridge.git.discard([p])),
|
||||||
ensureFile: (path) => {
|
ensureFile: (path) => {
|
||||||
if (dataRef.current.files[path] != null) return
|
if (dataRef.current.files[path] != null) return
|
||||||
|
|||||||
@@ -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 { 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:focus { border-color:var(--accent-line); }
|
||||||
.commit-input::placeholder { color:var(--fg-3); }
|
.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 { 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:hover:not(:disabled) { background:#f6b35f; }
|
||||||
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
|
.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
|
||||||
|
|||||||
Reference in New Issue
Block a user