update
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user