This commit is contained in:
2026-05-04 07:00:30 +00:00
parent 8a239cbd28
commit e0eca256d7
4 changed files with 420 additions and 18 deletions

107
app.py
View File

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

View File

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

View File

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

View File

@@ -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"/>