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

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