import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:yaml/yaml.dart'; enum SettingsScope { app, project, ext } class SettingsStore extends ChangeNotifier { SettingsStore({required this.appDir, this.projectDir, this.onError}); final Directory appDir; Directory? projectDir; /// Surfaces load/parse problems (wired to the kernel Logger by the /// facade). A parse failure must not pass silently — it used to reset /// every setting on the next write (T-376). final void Function(String message)? onError; final Map _appValues = {}; final Map _projectValues = {}; // Writes are fire-and-forget (callers don't await `set`); if the store is // disposed while one is mid-flight (app shutdown, a closing test), skip the // post-write notify rather than asserting on a disposed ChangeNotifier. bool _disposed = false; @override void dispose() { _disposed = true; super.dispose(); } void _safeNotify() { if (_disposed) return; notifyListeners(); } Future load() async { _appValues ..clear() ..addAll(await _readFile(_appFile)); _projectValues.clear(); if (projectDir != null) { _projectValues.addAll(await _readFile(_projectFile)); } _safeNotify(); } Future setProjectDir(Directory? dir) async { projectDir = dir; _projectValues.clear(); if (dir != null) { _projectValues.addAll(await _readFile(_projectFile)); } _safeNotify(); } File get _appFile => File('${appDir.path}/settings.yaml'); File get _projectFile => File('${projectDir!.path}/.clide/settings.yaml'); T? get(String key) { final v = _lookup(key); if (v is T) return v; if (T == int && v is num) return v.toInt() as T; if (T == double && v is num) return v.toDouble() as T; return null; } Object? _lookup(String key) { switch (_scopeOf(key)) { case SettingsScope.app: return _appValues[key]; case SettingsScope.project: return _projectValues[key]; case SettingsScope.ext: // project overrides app for the same ext.* key return _projectValues.containsKey(key) ? _projectValues[key] : _appValues[key]; } } Future set(String key, T value) async { switch (_scopeOf(key)) { case SettingsScope.app: _appValues[key] = value; await _writeFile(_appFile, _appValues); case SettingsScope.project: if (projectDir == null) { throw StateError('Cannot set project-scoped key with no project open: $key'); } _projectValues[key] = value; await _writeFile(_projectFile, _projectValues); case SettingsScope.ext: // default: store under app until an ext manifest requests project scope _appValues[key] = value; await _writeFile(_appFile, _appValues); } _safeNotify(); } Future> _readFile(File f) async { String txt; try { if (!await f.exists()) return {}; txt = await f.readAsString(); } catch (_) { // On web (or in sandboxes where the path isn't readable) silently // degrade to an empty in-memory catalog. `set` will no-op too. return {}; } if (txt.trim().isEmpty) return {}; try { final yaml = loadYaml(txt); final out = {}; if (yaml is Map) _flatten(yaml, '', out); return out; } catch (e) { // A parse failure must not silently reset the user's settings — the // next `set` overwrites the file with the (now empty) in-memory map. // Preserve the original for recovery and say so (T-376). try { await File('${f.path}.broken').writeAsString(txt); } catch (_) {} onError?.call('failed to parse ${f.path}: $e — original preserved at ${f.path}.broken'); return {}; } } Future _writeFile(File f, Map flat) async { try { await f.parent.create(recursive: true); // Temp-file + rename: a crash mid-write must not truncate the live // settings file (T-376). final tmp = File('${f.path}.tmp'); await tmp.writeAsString(_emitYaml(_unflatten(flat))); await tmp.rename(f.path); } catch (_) { // Web / read-only sandbox: in-memory update remains valid, we // just can't persist. Callers already called notifyListeners. } } static SettingsScope _scopeOf(String key) { if (key.startsWith('app.')) return SettingsScope.app; if (key.startsWith('project.')) return SettingsScope.project; if (key.startsWith('ext.')) return SettingsScope.ext; throw ArgumentError('Settings key must start with app.|project.|ext.: "$key"'); } } void _flatten(Map src, String prefix, Map into) { src.forEach((k, v) { final key = prefix.isEmpty ? '$k' : '$prefix.$k'; if (v is Map) { _flatten(v, key, into); } else if (v is YamlList) { into[key] = v.toList(); } else { into[key] = v; } }); } Map _unflatten(Map flat) { final root = {}; flat.forEach((k, v) { final parts = k.split('.'); var cursor = root; for (var i = 0; i < parts.length - 1; i++) { final next = cursor[parts[i]]; if (next is Map) { cursor = next; } else { final fresh = {}; cursor[parts[i]] = fresh; cursor = fresh; } } cursor[parts.last] = v; }); return root; } String _emitYaml(Object? value, {int indent = 0}) { final buf = StringBuffer(); _emit(buf, value, indent); return buf.toString(); } void _emit(StringBuffer buf, Object? v, int indent) { final pad = ' ' * indent; if (v is Map) { if (v.isEmpty) { buf.writeln('{}'); return; } v.forEach((k, vv) { buf.write('$pad$k:'); if (vv is Map && vv.isNotEmpty) { buf.writeln(); _emit(buf, vv, indent + 1); } else if (vv is List && vv.isNotEmpty) { buf.writeln(); for (final item in vv) { buf.write('$pad- '); _emitScalar(buf, item); buf.writeln(); } } else { buf.write(' '); _emitScalar(buf, vv); buf.writeln(); } }); return; } _emitScalar(buf, v); buf.writeln(); } void _emitScalar(StringBuffer buf, Object? v) { if (v == null) { buf.write('null'); } else if (v is bool || v is num) { buf.write(v); } else if (v is String) { if (_needsQuoting(v)) { buf.write('"${v.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"'); } else { buf.write(v); } } else if (v is List) { buf.write('['); for (var i = 0; i < v.length; i++) { if (i > 0) buf.write(', '); _emitScalar(buf, v[i]); } buf.write(']'); } else if (v is Map) { // YAML flow mapping — maps nested inside lists (e.g. keymap overlay // entries) used to fall through to toString() and corrupt on the // next read (T-376). buf.write('{'); var first = true; v.forEach((k, vv) { if (!first) buf.write(', '); first = false; _emitScalar(buf, '$k'); buf.write(': '); _emitScalar(buf, vv); }); buf.write('}'); } else { buf.write('"${v.toString()}"'); } } bool _needsQuoting(String s) { if (s.isEmpty) return true; if (RegExp(r'[:\#\n\r\t]').hasMatch(s)) return true; if (s != s.trim()) return true; const reserved = {'true', 'false', 'null', 'yes', 'no', 'on', 'off', '~'}; if (reserved.contains(s.toLowerCase())) return true; if (num.tryParse(s) != null) return true; return false; }