updated we better git unstaged chanegs markers, better gitignore handling in nested dirs, etc

This commit is contained in:
2026-05-03 20:46:36 +00:00
parent e335aa2ea3
commit 463bfb48a5
4 changed files with 121 additions and 40 deletions

4
.gitignore vendored
View File

@@ -33,3 +33,7 @@ Thumbs.db
.env
.env.local
.webui-secret
# debugging in docker
start.sh
restart.sh

99
app.py
View File

@@ -960,35 +960,80 @@ def _collect_gitignored(root, patterns, max_entries=2000):
return out
def _collect_repo_data(root, max_depth=6):
"""Walk root looking for nested git repos and stand-alone .gitignore files,
so opening a parent dir that contains multiple repos still greys out each
repo's ignored entries and surfaces each repo's working-tree changes.
Returns (status_files, ignored_paths). Both are keyed/relative to root."""
files = {}
ignored = set()
for cur, dirs, in_files in os.walk(root):
rel = os.path.relpath(cur, root)
if rel == '.':
rel = ''
depth = (rel.count(os.sep) + 1) if rel else 0
if depth > max_depth:
dirs[:] = []
continue
if '.git' in dirs or '.git' in in_files:
try:
ig = subprocess.run(
['git', 'ls-files', '--others', '--ignored',
'--exclude-standard', '--directory'],
cwd=cur, capture_output=True, text=True, timeout=5,
)
for ln in ig.stdout.splitlines():
ln = ln.rstrip('/').strip()
if not ln:
continue
ignored.add(os.path.join(rel, ln) if rel else ln)
except (OSError, subprocess.SubprocessError):
pass
try:
st = subprocess.run(
['git', 'status', '--porcelain'],
cwd=cur, capture_output=True, text=True, timeout=5,
)
if st.returncode == 0:
for line in st.stdout.splitlines():
if len(line) <= 3:
continue
xy = line[:2].strip()
name = line[3:].strip()
if ' -> ' in name:
name = name.split(' -> ')[1]
full = os.path.join(rel, name) if rel else name
files[full] = xy or '?'
except (OSError, subprocess.SubprocessError):
pass
dirs[:] = [] # nested repo handles its own subtree
continue
if '.gitignore' in in_files:
patterns = _read_gitignore_patterns(cur)
for p in _collect_gitignored(cur, patterns):
ignored.add(os.path.join(rel, p) if rel else p)
dirs[:] = [d for d in dirs if d not in SKIP_NAMES]
return files, list(ignored)
@app.route('/api/git/status')
def git_status():
path = request.args.get('path', '.')
files, ignored = _collect_repo_data(path)
is_git = False
try:
r = subprocess.run(['git', 'status', '--porcelain'], cwd=path,
capture_output=True, text=True, timeout=5)
files = {}
for line in r.stdout.splitlines():
if len(line) > 3:
xy = line[:2].strip()
name = line[3:].strip()
if ' -> ' in name:
name = name.split(' -> ')[1]
files[name] = xy or '?'
ignored = []
is_git = r.returncode == 0
if is_git:
ig = subprocess.run(
['git', 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory'],
cwd=path, capture_output=True, text=True, timeout=5,
)
ignored = [ln.rstrip('/') for ln in ig.stdout.splitlines() if ln.strip()]
else:
# Fallback: parse .gitignore directly so files still grey out before `git init`
patterns = _read_gitignore_patterns(path)
ignored = _collect_gitignored(path, patterns)
return jsonify({'files': files, 'ignored': ignored, 'is_git': is_git})
except Exception:
return jsonify({'files': {}, 'ignored': [], 'is_git': False})
r = subprocess.run(['git', 'rev-parse', '--is-inside-work-tree'],
cwd=path, capture_output=True, text=True, timeout=5)
is_git = r.returncode == 0 and r.stdout.strip() == 'true'
except (OSError, subprocess.SubprocessError):
pass
return jsonify({'files': files, 'ignored': ignored, 'is_git': is_git})
@app.route('/api/search')
@@ -1296,7 +1341,7 @@ def resume_session():
if __name__ == '__main__':
port = int(os.environ.get('PORT', 5000))
host = os.environ.get('HOST', '127.0.0.1')
port = int(os.environ.get('PORT', 61234))
host = os.environ.get('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)

View File

@@ -14,6 +14,7 @@ source .venv/bin/activate
pip install -q -r requirements.txt
PORT=${PORT:-5000}
PORT=${PORT:-45000}
HOST=10.30.0.10
echo "Starting Claude Code Web IDE on http://localhost:$PORT"
python app.py
python3 app.py

View File

@@ -1595,12 +1595,28 @@ function updateTermDot() { /* status dot removed */ }
// ─── File tree ────────────────────────────────────────────────────────────────
function recomputeDirtyDirs() {
const dirs = new Set();
for (const path of Object.keys(state.gitStatus || {})) {
if (!state.gitStatus[path]) continue;
let p = path;
while (true) {
const idx = p.lastIndexOf('/');
if (idx <= 0) break;
p = p.slice(0, idx);
dirs.add(p);
}
}
state.gitDirtyDirs = dirs;
}
async function refreshGitBadges() {
if (!state.cwd) return;
const data = await apiFetch(`/api/git/status?path=${enc(state.cwd)}`);
if (!data) return;
state.gitStatus = data.files || {};
state.gitIgnored = new Set(data.ignored || []);
recomputeDirtyDirs();
const cwdPrefix = state.cwd + '/';
document.querySelectorAll('#file-tree .tree-item').forEach(item => {
@@ -1608,10 +1624,15 @@ async function refreshGitBadges() {
if (!p) return;
const rel = p.startsWith(cwdPrefix) ? p.slice(cwdPrefix.length) : p;
const name = p.split('/').pop();
const isDir = item.classList.contains('tree-dir');
const status = state.gitStatus[rel] || '';
const ignored = state.gitIgnored.has(rel) || ALWAYS_DIM.has(name);
const fgClass = ignored ? 'fg-ignored' : gitStatusClass(status);
const badge = ignored ? '' : gitBadge(status);
let fgClass = ignored ? 'fg-ignored' : gitStatusClass(status);
let badge = ignored ? '' : gitBadge(status);
if (!ignored && isDir && !fgClass && state.gitDirtyDirs.has(rel)) {
fgClass = 'fg-modified';
badge = '<span class="tree-badge bg-modified">M</span>';
}
const nameEl = item.querySelector('.tree-name');
if (nameEl) {
@@ -1620,9 +1641,7 @@ async function refreshGitBadges() {
}
const oldBadge = item.querySelector('.tree-badge');
if (oldBadge) oldBadge.remove();
if (badge && item.classList.contains('tree-file')) {
item.insertAdjacentHTML('beforeend', badge);
}
if (badge) item.insertAdjacentHTML('beforeend', badge);
});
}
@@ -1634,6 +1653,7 @@ async function loadTree(path) {
]);
state.gitStatus = (gitData && gitData.files) || {};
state.gitIgnored = new Set((gitData && gitData.ignored) || []);
recomputeDirtyDirs();
renderTree(nodes || [], el('file-tree'), path);
attachRootDrop(el('file-tree'), path);
}
@@ -1662,8 +1682,13 @@ function renderNodes(nodes, parent, basePath, depth, expanded) {
const rel = node.path.startsWith(basePath + '/') ? node.path.slice(basePath.length + 1) : node.path;
const status = state.gitStatus[rel] || '';
const ignored = (state.gitIgnored && state.gitIgnored.has(rel)) || ALWAYS_DIM.has(node.name);
const fgClass = ignored ? 'fg-ignored' : gitStatusClass(status);
const badge = ignored ? '' : gitBadge(status);
let fgClass = ignored ? 'fg-ignored' : gitStatusClass(status);
let badge = ignored ? '' : gitBadge(status);
if (!ignored && node.type === 'dir' && !fgClass &&
state.gitDirtyDirs && state.gitDirtyDirs.has(rel)) {
fgClass = 'fg-modified';
badge = '<span class="tree-badge bg-modified">M</span>';
}
const item = document.createElement('div');
item.className = 'tree-item';
item.dataset.path = node.path;
@@ -1680,7 +1705,8 @@ function renderNodes(nodes, parent, basePath, depth, expanded) {
if (isOpen) item.classList.add('open');
item.innerHTML =
`<span class="tree-icon">${isOpen ? '▼' : '▶'}</span>` +
`<span class="tree-name dir-name ${fgClass}">${esc(node.name)}</span>`;
`<span class="tree-name dir-name ${fgClass}">${esc(node.name)}</span>` +
badge;
const children = document.createElement('div');
children.className = 'tree-children' + (isOpen ? '' : ' hidden');
if (node.children && node.children.length) renderNodes(node.children, children, basePath, depth + 1, expanded);
@@ -2658,10 +2684,15 @@ function addTab(path) {
tab.querySelector('.tab-close').addEventListener('click', (e) => {
e.stopPropagation();
const entry = state.openTabs[path];
if (entry && entry.dirty) {
const name = path.split('/').pop();
if (!confirm(`"${name}" has unsaved changes. Close without saving?`)) return;
}
const wasActive = tab.classList.contains('active');
if (state.openTabs[path]) {
try { state.openTabs[path].changeListener?.dispose(); } catch {}
state.openTabs[path].model.dispose();
if (entry) {
try { entry.changeListener?.dispose(); } catch {}
entry.model.dispose();
delete state.openTabs[path];
}
tab.remove();