This commit is contained in:
team3
2026-09-22 13:14:06 +02:00
parent a62b587340
commit 2472e89b9c
15 changed files with 461 additions and 28 deletions

65
desktop@marha.de/files.js Normal file
View File

@@ -0,0 +1,65 @@
// Dateiabgleich für ~/.claude/projects (Claude-Code-Sitzungen und Memory), nur zwischen PCs.
// Reine Logik ohne Dateisystem, damit sie sich testen lässt. sync.js liest und schreibt die Dateien.
//
// Regeln: Eine Datei geht hoch, wenn sie sich geändert hat und seit einer Stunde nicht angefasst wurde.
// Vom Server kommt sie, wenn sie dort neuer ist. Bei Konflikten gewinnt die neuere Änderungszeit.
// Verschwundene Dateien werden als Tombstone gemeldet, gelöschte vom Server lokal entfernt.
// Zeitstempel aus der Änderungszeit der Datei, gleiche Form wie updated bei Terminen
export function fileStamp(mtimeSeconds) {
return new Date(mtimeSeconds * 1000).toISOString();
}
export function stampToSeconds(stamp) {
return Math.floor(Date.parse(stamp) / 1000);
}
// Was hochgeladen wird. local: Map id → mtime in Sekunden. pushed: id → updated, null = als gelöscht gemeldet.
// upload: Einträge ohne Inhalt, remove: IDs, die es lokal nicht mehr gibt.
export function planPush(local, pushed, nowSeconds, idleSeconds) {
const upload = [];
for (const [id, mtime] of local) {
if (nowSeconds - mtime < idleSeconds)
continue;
const updated = fileStamp(mtime);
if (pushed[id] !== updated)
upload.push({id, updated});
}
const remove = Object.keys(pushed).filter(id => pushed[id] !== null && !local.has(id));
return {upload, remove};
}
// Was mit Einträgen vom Server passiert: write, delete oder skip.
// mine: der lokale Zeitstempel oder null, damit sync.js den Stand nachhalten kann.
export function planPull(remote, local) {
return remote.map(file => {
const mtime = local.get(file.id);
const mine = mtime === undefined ? null : fileStamp(mtime);
let action = 'write';
if (mine !== null && mine >= file.updated)
action = 'skip';
else if (file.deleted)
action = mine === null ? 'skip' : 'delete';
return {file, action, mine};
});
}
// Uploads in Pakete teilen, damit eine Anfrage nicht beliebig groß wird
export function batches(items, sizeOf, maxBytes) {
const out = [];
let current = [];
let size = 0;
for (const item of items) {
const bytes = sizeOf(item);
if (current.length && size + bytes > maxBytes) {
out.push(current);
current = [];
size = 0;
}
current.push(item);
size += bytes;
}
if (current.length)
out.push(current);
return out;
}

View File

@@ -77,3 +77,16 @@ function indent(spaces) {
const level = spaces.replace(/\t/g, ' ').length / 2;
return ' '.repeat(Math.floor(level) + 1);
}
// Was nach Enter am Ende dieser Zeile in der nächsten stehen soll.
// null: keine Liste. '': leerer Listenpunkt, der Marker soll weg. Sonst der Marker für den nächsten Punkt.
export function continueList(line) {
let m;
if ((m = line.match(/^(\s*)([-*+])\s+\[[ xX]\]\s+(.*)$/)))
return m[3].trim() ? `${m[1]}${m[2]} [ ] ` : '';
if ((m = line.match(/^(\s*)([-*+])\s+(.*)$/)))
return m[3].trim() ? `${m[1]}${m[2]} ` : '';
if ((m = line.match(/^(\s*)(\d+)([.)])\s+(.*)$/)))
return m[4].trim() ? `${m[1]}${Number(m[2]) + 1}${m[3]} ` : '';
return null;
}

View File

@@ -4,7 +4,7 @@ import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import St from 'gi://St';
import {renderMarkdown} from './markdown.js';
import {continueList, renderMarkdown} from './markdown.js';
import {contextMenu, multilineEntry, Popover, scrollBox, textButton} from './widgets.js';
const AUTOSAVE_SECONDS = 5;
@@ -50,6 +50,7 @@ class NotesPanel extends St.BoxLayout {
[this._editScroll, this._editor] = multilineEntry('', 'note-editor', 'note-editor-scroll');
this._editor.clutter_text.connect('text-changed', () => (this._dirty = true));
this._editor.clutter_text.connect('key-focus-out', () => this._stopEdit());
this._editor.clutter_text.connect('key-press-event', (text, event) => this._onEditorKey(text, event));
right.add_child(this._editScroll);
this.add_child(right);
@@ -102,13 +103,34 @@ class NotesPanel extends St.BoxLayout {
this._rowMenu(type, node);
else
onClick();
}, `tree-row ${type}`, type === 'folder' ? (this._expanded.has(node.path) ? 'folder-open-symbolic' : 'folder-symbolic') : 'text-x-generic-symbolic');
row.set_style(`padding-left: ${8 + depth * 16}px`);
}, `tree-row ${type}`, type === 'folder' ? (this._expanded.has(node.path) ? 'pan-down-symbolic' : 'pan-end-symbolic') : null);
// Notizen ohne Symbol, aber bündig mit dem Ordnernamen (Pfeil 14px + Abstand 6px)
row.set_style(`padding-left: ${8 + depth * 16 + (type === 'note' ? 20 : 0)}px`);
if (this._selected?.path === node.path)
row.add_style_class_name('selected');
this._tree.add_child(row);
}
// Enter in einer Liste setzt den nächsten Punkt, Enter auf einem leeren Punkt beendet die Liste.
_onEditorKey(clutterText, event) {
const symbol = event.get_key_symbol();
if (symbol !== Clutter.KEY_Return && symbol !== Clutter.KEY_KP_Enter)
return Clutter.EVENT_PROPAGATE;
if (event.get_state() & (Clutter.ModifierType.CONTROL_MASK | Clutter.ModifierType.SHIFT_MASK))
return Clutter.EVENT_PROPAGATE;
const text = clutterText.get_text();
const pos = clutterText.cursor_position < 0 ? text.length : clutterText.cursor_position;
const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
const next = continueList(text.slice(lineStart, pos));
if (next === null)
return Clutter.EVENT_PROPAGATE;
if (next === '')
clutterText.delete_text(lineStart, pos);
else
clutterText.insert_text(`\n${next}`, pos);
return Clutter.EVENT_STOP;
}
// Auswahl wechseln. Ein offener Editor wird vorher gespeichert.
_select(selected) {
this._stopEdit();

View File

@@ -2,6 +2,7 @@ import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Soup from 'gi://Soup';
import {batches, fileStamp, planPull, planPush, stampToSeconds} from './files.js';
import {readJson, writeJson} from './store.js';
// Abgleich mit dem Server. Zugang steht in sync.json im Datenordner:
@@ -22,6 +23,13 @@ const INTERVALS = [
// Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen
const SOON_MS = 500;
// Dateien (Claude-Sitzungen und Memory): alle 10 Minuten, hoch geht nur, was eine Stunde ruht.
// "files" in sync.json: false schaltet ab, ein Pfad ersetzt ~/.claude/projects.
const FILES_FIRST_SECONDS = 60;
const FILES_INTERVAL_SECONDS = 10 * 60;
const FILES_IDLE_SECONDS = 60 * 60;
const FILES_BATCH_BYTES = 16 * 1024 * 1024;
export class Sync {
constructor(store) {
this._store = store;
@@ -34,6 +42,11 @@ export class Sync {
this._state.pushed.folders = {};
this._state.cursor = 0;
}
this._state.files ??= {cursor: 0, pushed: {}};
const files = this._config?.files;
this._filesRoot = files === false ? null : (files ?? GLib.build_filenamev([GLib.get_home_dir(), '.claude', 'projects']));
this._filesTimerId = null;
this._filesBusy = false;
this._session = new Soup.Session({timeout: 10});
this._busy = false;
this._failing = false;
@@ -55,6 +68,8 @@ export class Sync {
this._store.onChange(() => this._scheduleSoon());
this._cycle().catch(e => this._fail(e.message));
this._scheduleNext();
if (this._filesRoot)
this._scheduleFiles(FILES_FIRST_SECONDS);
}
// Aktueller Takt in Sekunden, je nachdem wie lange die letzte Aktivität her ist.
@@ -98,6 +113,10 @@ export class Sync {
GLib.source_remove(this._timerId);
this._timerId = null;
}
if (this._filesTimerId) {
GLib.source_remove(this._filesTimerId);
this._filesTimerId = null;
}
if (this._soonId) {
GLib.source_remove(this._soonId);
this._soonId = null;
@@ -219,6 +238,78 @@ export class Sync {
writeJson(this._statePath, this._state);
}
// ── Dateien ──────────────────────────────────────────────────────────────
_scheduleFiles(seconds) {
if (this._stopped)
return;
this._filesTimerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, seconds, () => {
this._filesTimerId = null;
this._filesCycle().catch(e => this._fail(e.message)).finally(() => this._scheduleFiles(FILES_INTERVAL_SECONDS));
return GLib.SOURCE_REMOVE;
});
}
// Eigener Lauf mit eigenem Stand: die Antwort enthält nur Dateien, der Takt der Einträge bleibt unberührt.
async _filesCycle() {
if (this._filesBusy || !this._filesRoot)
return;
this._filesBusy = true;
try {
const state = this._state.files;
const local = scanFiles(this._filesRoot);
const now = Math.floor(GLib.get_real_time() / 1e6);
const {upload, remove} = planPush(local, state.pushed, now, FILES_IDLE_SECONDS);
const items = [];
for (const entry of upload) {
const content = readText(GLib.build_filenamev([this._filesRoot, entry.id]));
if (content !== null)
items.push({...entry, content});
}
const nowStamp = new Date(now * 1000).toISOString();
for (const id of remove)
items.push({id, updated: nowStamp, deleted: true});
// Mindestens ein Lauf, auch ohne eigene Änderungen: holt Neues vom Server
const packets = items.length ? batches(items, item => (item.content ?? '').length, FILES_BATCH_BYTES) : [[]];
for (const files of packets) {
const response = await this._post({since: state.cursor, files});
// Ein Server ohne Dateien-Sammlung würde alles verwerfen, der Stand bliebe sonst falsch
if (!Array.isArray(response.files))
throw new Error('Server kennt keine Dateien, dort erst make prod ausführen');
for (const item of files)
state.pushed[item.id] = item.deleted ? null : item.updated;
this._applyFiles(response, local);
writeJson(this._statePath, this._state);
}
} finally {
this._filesBusy = false;
}
}
_applyFiles(response, local) {
const state = this._state.files;
for (const {file, action, mine} of planPull(response.files ?? [], local)) {
const path = GLib.build_filenamev([this._filesRoot, file.id]);
if (action === 'write') {
GLib.mkdir_with_parents(GLib.path_get_dirname(path), 0o700);
GLib.file_set_contents(path, file.content);
const seconds = stampToSeconds(file.updated);
Gio.File.new_for_path(path).set_attribute_uint64('time::modified', seconds, Gio.FileQueryInfoFlags.NONE, null);
local.set(file.id, seconds);
state.pushed[file.id] = file.updated;
} else if (action === 'delete') {
GLib.unlink(path);
local.delete(file.id);
state.pushed[file.id] = null;
} else if (file.deleted && mine === null) {
state.pushed[file.id] = null;
} else if (mine === file.updated) {
state.pushed[file.id] = mine;
}
// Sonst ist die lokale Datei neuer und geht hoch, sobald sie eine Stunde ruht.
}
state.cursor = response.cursor;
}
_fail(message) {
if (this._stopped)
return;
@@ -227,3 +318,38 @@ export class Sync {
this._failing = true;
}
}
// Alle Dateien unter root: id (Pfad relativ zu root) → Änderungszeit in Sekunden
function scanFiles(root) {
const local = new Map();
const walk = (dir, prefix) => {
let enumerator;
try {
enumerator = dir.enumerate_children('standard::name,standard::type,time::modified', Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null);
} catch {
return;
}
let info;
while ((info = enumerator.next_file(null))) {
const name = info.get_name();
const id = prefix ? `${prefix}/${name}` : name;
if (info.get_file_type() === Gio.FileType.DIRECTORY)
walk(dir.get_child(name), id);
else if (info.get_file_type() === Gio.FileType.REGULAR)
local.set(id, Number(info.get_attribute_uint64('time::modified')));
}
enumerator.close(null);
};
walk(Gio.File.new_for_path(root), '');
return local;
}
// Textdatei lesen. null, wenn sie fehlt oder kein UTF-8 ist.
function readText(path) {
try {
const [, bytes] = GLib.file_get_contents(path);
return new TextDecoder('utf-8', {fatal: true}).decode(bytes);
} catch {
return null;
}
}