updated
This commit is contained in:
107
app.py
107
app.py
@@ -22,16 +22,29 @@ from datetime import datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from dotenv import dotenv_values
|
||||
from flask import (Flask, jsonify, redirect, render_template, request, abort,
|
||||
send_file, session as flask_session, url_for)
|
||||
from flask_socketio import SocketIO, emit, join_room, leave_room
|
||||
|
||||
|
||||
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
load_dotenv(os.path.join(_APP_DIR, '.env'))
|
||||
# Read .env into a private dict — NOT into os.environ — so that values like
|
||||
# HOST/PORT/WEBUI_PASSWORD don't leak into shells we spawn (which would
|
||||
# clobber $HOST in the prompt and expose the password to `env` in the PTY).
|
||||
_DOTENV = {k: v for k, v in dotenv_values(os.path.join(_APP_DIR, '.env')).items() if v is not None}
|
||||
|
||||
WEBUI_PASSWORD = os.environ.get('WEBUI_PASSWORD') or 'loldongs'
|
||||
def _cfg(key, default=None):
|
||||
return _DOTENV.get(key, os.environ.get(key, default))
|
||||
|
||||
WEBUI_PASSWORD = _cfg('WEBUI_PASSWORD') or 'admin'
|
||||
|
||||
# Defence in depth — never let these leak into a PTY child:
|
||||
# - WEBUI_PASSWORD: secret
|
||||
# - HOST/PORT: Flask's app.run auto-loads .env into os.environ regardless
|
||||
# of how we read it ourselves, which would clobber zsh's $HOST in the
|
||||
# prompt and confuse anything else that reads $PORT in the shell.
|
||||
_PTY_ENV_SCRUB = ('WEBUI_PASSWORD', 'HOST', 'PORT')
|
||||
|
||||
|
||||
# ---------- persistent secret key (so restarts don't log everyone out) ----------
|
||||
@@ -150,6 +163,8 @@ def _start_pty(session_id, cwd, args=None, cmd=None):
|
||||
fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack('HHHH', 50, 220, 0, 0))
|
||||
|
||||
env = dict(os.environ)
|
||||
for k in _PTY_ENV_SCRUB:
|
||||
env.pop(k, None)
|
||||
env.update({
|
||||
'TERM': 'xterm-256color',
|
||||
'COLORTERM': 'truecolor',
|
||||
@@ -327,6 +342,28 @@ def file_tree():
|
||||
return jsonify(build(path))
|
||||
|
||||
|
||||
@app.route('/api/list-dirs')
|
||||
def list_dirs():
|
||||
raw = request.args.get('path', '~')
|
||||
if raw.startswith('~'):
|
||||
raw = os.path.expanduser(raw)
|
||||
path = os.path.realpath(raw or os.path.expanduser('~'))
|
||||
if not os.path.isdir(path):
|
||||
return jsonify({'path': path, 'dirs': [], 'error': 'not a directory'}), 200
|
||||
try:
|
||||
names = []
|
||||
for e in os.scandir(path):
|
||||
try:
|
||||
if e.is_dir(follow_symlinks=False):
|
||||
names.append(e.name)
|
||||
except OSError:
|
||||
continue
|
||||
names.sort(key=str.lower)
|
||||
return jsonify({'path': path, 'dirs': names})
|
||||
except OSError as exc:
|
||||
return jsonify({'path': path, 'dirs': [], 'error': str(exc)}), 200
|
||||
|
||||
|
||||
@app.route('/api/file', methods=['GET'])
|
||||
def read_file():
|
||||
path = request.args.get('path', '')
|
||||
@@ -664,6 +701,61 @@ def git_show():
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
|
||||
|
||||
@app.route('/api/git/checkout', methods=['POST'])
|
||||
def git_checkout():
|
||||
data = request.get_json() or {}
|
||||
path = data.get('path', '')
|
||||
branch = (data.get('branch') or '').strip()
|
||||
create = bool(data.get('create'))
|
||||
if not path or not os.path.isdir(path):
|
||||
return jsonify({'error': 'invalid path'}), 400
|
||||
if not branch or not re.match(r'^[A-Za-z0-9_./+-]{1,200}$', branch):
|
||||
return jsonify({'error': 'invalid branch name'}), 400
|
||||
cmd = ['git', 'checkout']
|
||||
if create:
|
||||
cmd.append('-b')
|
||||
cmd.append(branch)
|
||||
try:
|
||||
r = subprocess.run(cmd, cwd=path, capture_output=True, text=True, timeout=15)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
if r.returncode != 0:
|
||||
return jsonify({'error': (r.stderr or r.stdout).strip() or 'checkout failed'}), 400
|
||||
return jsonify({'ok': True, 'output': (r.stdout + r.stderr).strip()})
|
||||
|
||||
|
||||
@app.route('/api/git/commit', methods=['POST'])
|
||||
def git_commit():
|
||||
data = request.get_json() or {}
|
||||
path = data.get('path', '')
|
||||
msg = (data.get('message') or '').strip()
|
||||
if not path or not os.path.isdir(path):
|
||||
return jsonify({'error': 'invalid path'}), 400
|
||||
if not msg:
|
||||
return jsonify({'error': 'commit message required'}), 400
|
||||
|
||||
env = dict(os.environ)
|
||||
env['GIT_TERMINAL_PROMPT'] = '0' # never hang waiting for credentials
|
||||
|
||||
try:
|
||||
add = subprocess.run(['git', 'add', '-A'], cwd=path,
|
||||
capture_output=True, text=True, timeout=30, env=env)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return jsonify({'error': 'git add: ' + str(exc)}), 400
|
||||
if add.returncode != 0:
|
||||
return jsonify({'error': 'git add failed: ' +
|
||||
((add.stderr or add.stdout).strip() or 'unknown')}), 400
|
||||
|
||||
try:
|
||||
cm = subprocess.run(['git', 'commit', '-m', msg], cwd=path,
|
||||
capture_output=True, text=True, timeout=30, env=env)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return jsonify({'error': 'git commit: ' + str(exc)}), 400
|
||||
if cm.returncode != 0:
|
||||
return jsonify({'error': (cm.stderr or cm.stdout).strip() or 'commit failed'}), 400
|
||||
return jsonify({'ok': True, 'output': (cm.stdout + cm.stderr).strip()})
|
||||
|
||||
|
||||
@app.route('/api/diff')
|
||||
def get_diff():
|
||||
path = request.args.get('path', '.')
|
||||
@@ -1320,7 +1412,10 @@ def resume_session():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
port = int(os.environ.get('PORT', 5005))
|
||||
host = os.environ.get('HOST', '0.0.0.0')
|
||||
port = int(_cfg('PORT', 5005))
|
||||
host = _cfg('HOST', '0.0.0.0')
|
||||
print(f'Claude Code Web IDE → http://{host}:{port}')
|
||||
socketio.run(app, host=host, port=port, debug=False, allow_unsafe_werkzeug=True)
|
||||
# load_dotenv=False stops Flask from re-loading .env into os.environ at
|
||||
# boot, which would otherwise undo our private _DOTENV setup.
|
||||
socketio.run(app, host=host, port=port, debug=False,
|
||||
allow_unsafe_werkzeug=True, load_dotenv=False)
|
||||
|
||||
@@ -259,6 +259,46 @@ body.trippy .session-card.current {
|
||||
}
|
||||
.startup-dir-row .btn { padding: 8px 18px; font-size: 13px; border-radius: var(--radius); flex-shrink: 0; }
|
||||
|
||||
.dir-suggest {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--canvas-inset);
|
||||
max-height: 220px; overflow-y: auto;
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
}
|
||||
.dir-suggest.hidden { display: none; }
|
||||
.dir-suggest-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 10px; cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.dir-suggest-item:hover,
|
||||
.dir-suggest-item.active {
|
||||
background: var(--surface0); color: var(--text);
|
||||
}
|
||||
.dir-suggest-icon { color: var(--accent); flex-shrink: 0; }
|
||||
.dir-suggest-empty {
|
||||
padding: 8px 10px; color: var(--text-muted);
|
||||
font-size: 11px; font-style: italic;
|
||||
}
|
||||
|
||||
.startup-section-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.startup-clear-btn {
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 10px; font-weight: 600;
|
||||
letter-spacing: .08em; text-transform: uppercase;
|
||||
padding: 3px 8px; border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
}
|
||||
.startup-clear-btn:hover {
|
||||
color: var(--text); border-color: var(--text-muted);
|
||||
background: var(--surface0);
|
||||
}
|
||||
|
||||
.startup-scroll { overflow-y: auto; max-height: 240px; }
|
||||
.startup-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
@@ -437,7 +477,42 @@ body.trippy .session-card.current {
|
||||
font-size: 9px; font-weight: 700; letter-spacing: .1em;
|
||||
color: var(--text-muted); text-transform: uppercase;
|
||||
margin-bottom: 6px; user-select: none;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.git-section-btn {
|
||||
margin-left: auto;
|
||||
background: transparent; border: 1px solid var(--border);
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
width: 18px; height: 18px; border-radius: var(--radius);
|
||||
font-size: 13px; line-height: 1; padding: 0;
|
||||
}
|
||||
.git-section-btn:hover { color: var(--text); border-color: var(--text-muted); background: var(--surface0); }
|
||||
|
||||
.git-commit-msg {
|
||||
width: 100%; box-sizing: border-box;
|
||||
background: var(--canvas-inset); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 6px 8px; font-family: var(--font-mono); font-size: 12px;
|
||||
resize: vertical; margin-bottom: 6px;
|
||||
}
|
||||
.git-commit-msg:focus { outline: none; border-color: var(--accent); }
|
||||
.git-commit-msg:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.git-commit-btn {
|
||||
width: 100%; padding: 6px 10px; font-size: 12px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.git-commit-btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.git-commit-error {
|
||||
margin-top: 6px; padding: 6px 8px;
|
||||
background: rgba(248, 81, 73, .1); border: 1px solid rgba(248, 81, 73, .3);
|
||||
border-radius: var(--radius);
|
||||
color: #ff7b72; font-size: 11px;
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.git-commit-error.hidden { display: none; }
|
||||
.git-branch-row-item { cursor: pointer; }
|
||||
.git-branch-row-item.current { cursor: default; }
|
||||
.git-branch-row-item:not(.current):hover { background: var(--surface0); }
|
||||
.git-head { background: var(--canvas); }
|
||||
.git-branch-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
@@ -1126,6 +1201,7 @@ kbd {
|
||||
background: var(--canvas-inset);
|
||||
}
|
||||
|
||||
|
||||
/* ── stats pills ── */
|
||||
.ts-pill {
|
||||
display: inline-flex; align-items: center; gap: 3px;
|
||||
|
||||
238
static/js/app.js
238
static/js/app.js
@@ -516,9 +516,9 @@ function initUI() {
|
||||
|
||||
// Open directory (re-uses startup modal)
|
||||
el('btn-open-dir').addEventListener('click', () => {
|
||||
el('startup-dir').value = state.cwd;
|
||||
el('startup-dir').value = homeRel(state.cwd);
|
||||
el('modal-startup').classList.remove('hidden');
|
||||
setTimeout(() => el('startup-dir').focus(), 50);
|
||||
setTimeout(focusStartupDirEnd, 50);
|
||||
});
|
||||
|
||||
// New session
|
||||
@@ -568,6 +568,10 @@ function initUI() {
|
||||
if (state.activePane === 'shell') respawnShell();
|
||||
else refitTerminal();
|
||||
});
|
||||
el('btn-scroll-bottom').addEventListener('click', () => {
|
||||
const term = state.activePane === 'shell' ? state.shellTerm : state.term;
|
||||
if (term) { try { term.scrollToBottom(); } catch {} }
|
||||
});
|
||||
el('btn-md-preview').addEventListener('click', () => {
|
||||
if (state.currentFile) openMarkdownPreview(state.currentFile);
|
||||
});
|
||||
@@ -603,6 +607,13 @@ function prettyPath(p) {
|
||||
return p;
|
||||
}
|
||||
|
||||
function homeRel(p) {
|
||||
if (!p) return '~/';
|
||||
if (window.HOME_DIR && p === window.HOME_DIR) return '~/';
|
||||
if (window.HOME_DIR && p.startsWith(window.HOME_DIR + '/')) return '~' + p.slice(window.HOME_DIR.length);
|
||||
return p;
|
||||
}
|
||||
|
||||
function normCwd(p) {
|
||||
if (!p) return '';
|
||||
let s = String(p).trim();
|
||||
@@ -795,10 +806,13 @@ async function newSkill(commandsDir) {
|
||||
|
||||
function updateFitTerminalButton() {
|
||||
const btn = el('btn-fit-terminal');
|
||||
if (!btn) return;
|
||||
const scrollBtn = el('btn-scroll-bottom');
|
||||
const show = state.activePane === 'terminal' || state.activePane === 'shell';
|
||||
btn.classList.toggle('hidden', !show);
|
||||
btn.title = state.activePane === 'shell' ? 'Restart shell (fresh in cwd)' : 'Refit terminal';
|
||||
if (btn) {
|
||||
btn.classList.toggle('hidden', !show);
|
||||
btn.title = state.activePane === 'shell' ? 'Restart shell (fresh in cwd)' : 'Refit terminal';
|
||||
}
|
||||
if (scrollBtn) scrollBtn.classList.toggle('hidden', !show);
|
||||
}
|
||||
|
||||
function updateEditorChromeButtons() {
|
||||
@@ -1304,7 +1318,7 @@ async function loadClaudeHistory() {
|
||||
|
||||
async function showStartupModal() {
|
||||
const modal = el('modal-startup');
|
||||
el('startup-dir').value = state.cwd;
|
||||
el('startup-dir').value = homeRel(state.cwd);
|
||||
|
||||
const recents = getRecentDirs();
|
||||
if (recents.length) {
|
||||
@@ -1339,12 +1353,126 @@ async function showStartupModal() {
|
||||
el('startup-open-btn').addEventListener('click', () => {
|
||||
dismissStartup(el('startup-dir').value.trim() || state.cwd);
|
||||
});
|
||||
const dirAuto = createDirAutocomplete(el('startup-dir'), el('startup-dir-suggest'));
|
||||
el('startup-dir').addEventListener('keydown', (e) => {
|
||||
if (dirAuto.handleKeydown(e)) return;
|
||||
if (e.key === 'Enter') dismissStartup(el('startup-dir').value.trim() || state.cwd);
|
||||
});
|
||||
|
||||
el('startup-clear-recents').addEventListener('click', () => {
|
||||
if (!confirm('Clear recent directories?')) return;
|
||||
localStorage.removeItem('recentDirs');
|
||||
el('startup-recents-list').innerHTML = '';
|
||||
el('startup-recents-section').classList.add('hidden');
|
||||
});
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
setTimeout(() => el('startup-dir').focus(), 80);
|
||||
setTimeout(focusStartupDirEnd, 80);
|
||||
}
|
||||
|
||||
function focusStartupDirEnd() {
|
||||
const input = el('startup-dir');
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
const len = input.value.length;
|
||||
try { input.setSelectionRange(len, len); } catch {}
|
||||
}
|
||||
|
||||
function createDirAutocomplete(input, panel) {
|
||||
let items = [];
|
||||
let activeIdx = -1;
|
||||
let parentPath = '';
|
||||
let debounce = null;
|
||||
|
||||
async function refresh() {
|
||||
const raw = input.value;
|
||||
let parent, prefix;
|
||||
const slash = raw.lastIndexOf('/');
|
||||
if (slash >= 0) {
|
||||
parent = raw.slice(0, slash) || '/';
|
||||
prefix = raw.slice(slash + 1);
|
||||
} else {
|
||||
parent = '~';
|
||||
prefix = raw;
|
||||
}
|
||||
const data = await apiFetch(`/api/list-dirs?path=${enc(parent)}`);
|
||||
if (!data) { hide(); return; }
|
||||
const lo = prefix.toLowerCase();
|
||||
let matches = (data.dirs || []).filter(n => n.toLowerCase().startsWith(lo));
|
||||
if (!matches.length && lo) {
|
||||
matches = (data.dirs || []).filter(n => n.toLowerCase().includes(lo));
|
||||
}
|
||||
items = matches.slice(0, 200);
|
||||
parentPath = homeRel(data.path || parent);
|
||||
activeIdx = -1;
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
panel.innerHTML = '';
|
||||
if (!items.length) {
|
||||
panel.innerHTML = '<div class="dir-suggest-empty">No matching directories</div>';
|
||||
panel.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
items.forEach((name, i) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'dir-suggest-item' + (i === activeIdx ? ' active' : '');
|
||||
row.innerHTML = `<span class="dir-suggest-icon">📁</span><span>${esc(name)}</span>`;
|
||||
row.addEventListener('mousedown', (ev) => { ev.preventDefault(); pick(i); });
|
||||
panel.appendChild(row);
|
||||
});
|
||||
panel.classList.remove('hidden');
|
||||
if (activeIdx >= 0) {
|
||||
const active = panel.children[activeIdx];
|
||||
if (active && active.scrollIntoView) active.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
function pick(i) {
|
||||
if (i < 0 || i >= items.length) return;
|
||||
const sep = parentPath.endsWith('/') ? '' : '/';
|
||||
input.value = parentPath + sep + items[i] + '/';
|
||||
input.focus();
|
||||
refresh();
|
||||
}
|
||||
|
||||
function hide() { panel.classList.add('hidden'); items = []; activeIdx = -1; }
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(refresh, 120);
|
||||
});
|
||||
input.addEventListener('focus', refresh);
|
||||
input.addEventListener('blur', () => setTimeout(hide, 120));
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (panel.classList.contains('hidden')) return false;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (items.length) { activeIdx = (activeIdx + 1) % items.length; render(); }
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (items.length) { activeIdx = (activeIdx - 1 + items.length) % items.length; render(); }
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Tab' && items.length) {
|
||||
e.preventDefault();
|
||||
pick(activeIdx >= 0 ? activeIdx : 0);
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'Escape') { hide(); return true; }
|
||||
if (e.key === 'Enter' && activeIdx >= 0) {
|
||||
e.preventDefault();
|
||||
pick(activeIdx);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return { refresh, hide, handleKeydown };
|
||||
}
|
||||
|
||||
function mkStartupItem(icon, path, preview, time, badgeKind) {
|
||||
@@ -1999,6 +2127,24 @@ function renderGitPanel(data) {
|
||||
? `<div class="git-remote" title="${esc(data.remote_url)}">${esc(data.remote_url)}</div>`
|
||||
: '<div class="git-remote dim">no remote</div>';
|
||||
|
||||
const dirtyCount = (st.staged || 0) + (st.modified || 0) + (st.untracked || 0);
|
||||
const commitSection = `
|
||||
<div class="git-section">
|
||||
<div class="git-section-title">
|
||||
Commit
|
||||
${dirtyCount ? `<span class="dim">(${dirtyCount} change${dirtyCount === 1 ? '' : 's'})</span>` : '<span class="dim">(working tree clean)</span>'}
|
||||
</div>
|
||||
<textarea id="git-commit-msg" class="git-commit-msg"
|
||||
placeholder="Commit message…" rows="2"
|
||||
${dirtyCount ? '' : 'disabled'}></textarea>
|
||||
<button id="git-commit-btn" class="btn btn-primary git-commit-btn"
|
||||
type="button" ${dirtyCount ? '' : 'disabled'}>
|
||||
Stage all & commit
|
||||
</button>
|
||||
<div id="git-commit-error" class="git-commit-error hidden"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
host.innerHTML = `
|
||||
<div class="git-section git-head">
|
||||
<div class="git-branch-row">
|
||||
@@ -2017,11 +2163,16 @@ function renderGitPanel(data) {
|
||||
<div class="git-status-row">${statusBits.join('')}</div>
|
||||
</div>
|
||||
|
||||
${commitSection}
|
||||
|
||||
<div class="git-section">
|
||||
<div class="git-section-title">Branches <span class="dim">(${data.branches.length})</span></div>
|
||||
<div class="git-section-title">
|
||||
Branches <span class="dim">(${data.branches.length})</span>
|
||||
<button id="git-new-branch-btn" class="git-section-btn" type="button" title="New branch">+</button>
|
||||
</div>
|
||||
<div class="git-list">
|
||||
${data.branches.map(b =>
|
||||
`<div class="git-row${b.current ? ' current' : ''}" title="${esc(b.name)}">
|
||||
`<div class="git-row git-branch-row-item${b.current ? ' current' : ''}" data-branch="${esc(b.name)}" title="${b.current ? 'Current branch' : 'Click to checkout ' + esc(b.name)}">
|
||||
<span class="git-row-icon">${b.current ? '●' : '○'}</span>
|
||||
<span class="git-row-name">${esc(b.name)}</span>
|
||||
</div>`
|
||||
@@ -2067,6 +2218,75 @@ function renderGitPanel(data) {
|
||||
host.querySelectorAll('.git-commit').forEach(row => {
|
||||
row.addEventListener('click', () => openDiffTab(row.dataset.rev, row.dataset.subject));
|
||||
});
|
||||
|
||||
const commitBtn = el('git-commit-btn');
|
||||
if (commitBtn) commitBtn.addEventListener('click', doGitCommit);
|
||||
|
||||
const newBranchBtn = el('git-new-branch-btn');
|
||||
if (newBranchBtn) newBranchBtn.addEventListener('click', () => {
|
||||
const name = prompt('New branch name:');
|
||||
if (!name) return;
|
||||
doGitCheckout(name.trim(), true);
|
||||
});
|
||||
|
||||
host.querySelectorAll('.git-branch-row-item').forEach(row => {
|
||||
if (row.classList.contains('current')) return;
|
||||
row.addEventListener('click', () => {
|
||||
const name = row.dataset.branch;
|
||||
if (!name) return;
|
||||
if (dirtyCount && !confirm(`Switch to '${name}'? You have ${dirtyCount} uncommitted change${dirtyCount === 1 ? '' : 's'} — git will refuse if any would be overwritten.`)) return;
|
||||
doGitCheckout(name, false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function doGitCommit() {
|
||||
const msg = (el('git-commit-msg').value || '').trim();
|
||||
const err = el('git-commit-error');
|
||||
err.classList.add('hidden');
|
||||
err.textContent = '';
|
||||
if (!msg) {
|
||||
err.textContent = 'Commit message required.';
|
||||
err.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
const btn = el('git-commit-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Committing…';
|
||||
try {
|
||||
const r = await fetch('/api/git/commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: state.cwd, message: msg }),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || data.error) {
|
||||
err.textContent = data.error || `commit failed (HTTP ${r.status})`;
|
||||
err.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
el('git-commit-msg').value = '';
|
||||
loadGitPanel();
|
||||
loadTree(state.cwd);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Stage all & commit';
|
||||
}
|
||||
}
|
||||
|
||||
async function doGitCheckout(branch, create) {
|
||||
const r = await fetch('/api/git/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: state.cwd, branch, create: !!create }),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok || data.error) {
|
||||
alert('Checkout failed:\n' + (data.error || `HTTP ${r.status}`));
|
||||
return;
|
||||
}
|
||||
loadGitPanel();
|
||||
loadTree(state.cwd);
|
||||
}
|
||||
|
||||
async function openDiffTab(rev, subject) {
|
||||
|
||||
@@ -45,13 +45,17 @@
|
||||
<div class="startup-section">
|
||||
<label>Open Directory</label>
|
||||
<div class="startup-dir-row">
|
||||
<input type="text" id="startup-dir" value="{{ default_cwd }}"
|
||||
placeholder="/path/to/project" autocomplete="off" spellcheck="false">
|
||||
<input type="text" id="startup-dir" value="~/"
|
||||
placeholder="~/path/to/project" autocomplete="off" spellcheck="false">
|
||||
<button id="startup-open-btn" class="btn btn-primary">Open</button>
|
||||
</div>
|
||||
<div id="startup-dir-suggest" class="dir-suggest hidden"></div>
|
||||
</div>
|
||||
<div id="startup-recents-section" class="startup-section hidden">
|
||||
<label>Recent</label>
|
||||
<div class="startup-section-head">
|
||||
<label>Recent</label>
|
||||
<button id="startup-clear-recents" class="startup-clear-btn" type="button">Clear</button>
|
||||
</div>
|
||||
<div id="startup-recents-list"></div>
|
||||
</div>
|
||||
<div id="startup-sessions-section" class="startup-section hidden">
|
||||
@@ -280,6 +284,13 @@
|
||||
<circle cx="8" cy="8" r="2.2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-scroll-bottom" class="tab-action tab-action-icon hidden" title="Scroll to bottom">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 2.5v9"/>
|
||||
<polyline points="4,8 8,12 12,8"/>
|
||||
<line x1="3" y1="14" x2="13" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="btn-fit-terminal" class="tab-action tab-action-icon hidden" title="Refit terminal">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="2,6 2,2 6,2"/>
|
||||
|
||||
Reference in New Issue
Block a user