updated we better git unstaged chanegs markers, better gitignore handling in nested dirs, etc
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -33,3 +33,7 @@ Thumbs.db
|
||||
.env
|
||||
.env.local
|
||||
.webui-secret
|
||||
|
||||
# debugging in docker
|
||||
start.sh
|
||||
restart.sh
|
||||
|
||||
99
app.py
99
app.py
@@ -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)
|
||||
|
||||
5
start.sh
5
start.sh
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user