dissolve app/ into repo root (D-056)
Single Flutter package at the repo root. All code, tests, assets, and platform directories moved from app/ to root. Package renamed from clide_app to clide — all imports rewritten. Merged pubspec combines core (ffi) and app (flutter, yaml, xterm) dependencies. Makefile simplified: no APP_PRESENT conditionals, no cd, no daemon lifecycle. 317 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tab.title": { "translation": "Claude" },
|
||||
"status.attaching": { "translation": "attaching…" },
|
||||
"status.no-tmux": { "translation": "no-tmux · fresh every launch" },
|
||||
"status.exited": { "translation": "session exited" },
|
||||
"status.primary-exited": { "translation": "session exited — restart clide to retry" }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"command.reset": { "translation": "Layout: Reset to Classic" },
|
||||
"preset.classic": { "translation": "Classic" }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tab.title": { "translation": "Editor" },
|
||||
"empty": { "translation": "Open a file to begin editing." },
|
||||
"subtitle.no-buffer": { "translation": "no buffer · use `clide open <path>` or pick a file in the tree" }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"tab.title": { "translation": "Files" },
|
||||
"loading": { "translation": "Loading…" },
|
||||
"empty": { "translation": "No visible files" }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"connected": { "translation": "connected" },
|
||||
"connected.hint": { "translation": "clide daemon is reachable over the local socket" },
|
||||
"disconnected": { "translation": "disconnected" },
|
||||
"disconnected.hint": { "translation": "clide daemon is not running — start it with `clide --daemon`" }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tab.title": { "translation": "Terminal" },
|
||||
"subtitle.spawning": { "translation": "spawning shell…" },
|
||||
"subtitle.exited": { "translation": "Shell exited." },
|
||||
"error.unavailable": { "translation": "Terminal unavailable" },
|
||||
"error.daemon": { "translation": "Daemon not connected. Start `clide --daemon`." }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"command.pick": { "translation": "Theme: Pick…" },
|
||||
"modal.title": { "translation": "Select theme" },
|
||||
"modal.cancel": { "translation": "Cancel" },
|
||||
"modal.cancel.hint": { "translation": "Close the theme picker without changing the current theme" },
|
||||
"row.select.hint": { "translation": "Activate this theme" }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": { "translation": "clide" },
|
||||
"subtitle": { "translation": "Flutter desktop IDE for Claude Code" },
|
||||
"open-project": { "translation": "Open project" },
|
||||
"open-project.hint": { "translation": "Pick a git repository to open as the workspace" },
|
||||
"tab.title": { "translation": "Welcome" }
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clide/kernel/src/i18n/fallback_chain.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Loads catalog JSON for a given `(namespace, locale)` pair.
|
||||
///
|
||||
/// The file format (mirrors fframe verbatim):
|
||||
/// `{namespace}_{lang}_{country}.json` — or `{namespace}_{lang}.json`
|
||||
/// Content: `{ "key": { "translation": "...", ...extras }, ... }`.
|
||||
///
|
||||
/// Two reader shapes:
|
||||
/// * Asset bundle (built-in catalogs shipped under `lib/kernel/src/i18n/catalog/`).
|
||||
/// * Filesystem (third-party extensions under `~/.clide/extensions/<id>/`).
|
||||
///
|
||||
/// Missing files return an empty map — not an error. The fallback chain
|
||||
/// walker handles "nothing for this locale" by trying the next one.
|
||||
abstract class CatalogLoader {
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale);
|
||||
}
|
||||
|
||||
class AssetCatalogLoader implements CatalogLoader {
|
||||
AssetCatalogLoader({required this.bundle, this.rootDir = _defaultRoot});
|
||||
|
||||
final AssetBundle bundle;
|
||||
final String rootDir;
|
||||
|
||||
static const String _defaultRoot = 'lib/kernel/src/i18n/catalog';
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
|
||||
final suffix = FallbackChain.filenameSuffix(locale);
|
||||
final path = '$rootDir/${namespace}_$suffix.json';
|
||||
try {
|
||||
final text = await bundle.loadString(path);
|
||||
if (text.trim().isEmpty) return const {};
|
||||
final obj = jsonDecode(text);
|
||||
if (obj is Map) return obj.cast<String, Object?>();
|
||||
return const {};
|
||||
} on FlutterError {
|
||||
// Asset missing. Return empty map; fallback chain handles the miss.
|
||||
return const {};
|
||||
} on FormatException {
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FileCatalogLoader implements CatalogLoader {
|
||||
const FileCatalogLoader({required this.rootDir});
|
||||
|
||||
final Directory rootDir;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
|
||||
final suffix = FallbackChain.filenameSuffix(locale);
|
||||
final f = File('${rootDir.path}/${namespace}_$suffix.json');
|
||||
if (!await f.exists()) return const {};
|
||||
try {
|
||||
final text = await f.readAsString();
|
||||
if (text.trim().isEmpty) return const {};
|
||||
final obj = jsonDecode(text);
|
||||
if (obj is Map) return obj.cast<String, Object?>();
|
||||
} on FormatException {
|
||||
// malformed — return empty; caller will fall back.
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Preloaded-in-memory loader for tests and synthesized catalogs.
|
||||
class InMemoryCatalogLoader implements CatalogLoader {
|
||||
InMemoryCatalogLoader(this._map);
|
||||
|
||||
final Map<String, Map<Locale, Map<String, Object?>>> _map;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> load(String namespace, Locale locale) async {
|
||||
final byNs = _map[namespace];
|
||||
if (byNs == null) return const {};
|
||||
// match by canonical comparison so Locale("en") == registered Locale("en")
|
||||
for (final entry in byNs.entries) {
|
||||
if (_eq(entry.key, locale)) return entry.value;
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
|
||||
static bool _eq(Locale a, Locale b) =>
|
||||
a.languageCode == b.languageCode && a.countryCode == b.countryCode;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Resolves the ordered list of locales to try when looking up a key.
|
||||
///
|
||||
/// Order, starting from the current locale:
|
||||
/// 1. exact (language + country) — e.g. nl_NL
|
||||
/// 2. language-only — e.g. nl
|
||||
/// 3. default language + country — e.g. en_US
|
||||
/// 4. default language-only — e.g. en
|
||||
///
|
||||
/// Duplicates are removed while preserving order. `null` country code is
|
||||
/// canonicalized by omitting it (not empty string) so equality works.
|
||||
@immutable
|
||||
class FallbackChain {
|
||||
const FallbackChain({
|
||||
required this.current,
|
||||
required this.defaultLocale,
|
||||
});
|
||||
|
||||
final Locale current;
|
||||
final Locale defaultLocale;
|
||||
|
||||
List<Locale> resolve() {
|
||||
final out = <Locale>[];
|
||||
for (final l in [
|
||||
current,
|
||||
Locale(current.languageCode),
|
||||
defaultLocale,
|
||||
Locale(defaultLocale.languageCode),
|
||||
]) {
|
||||
final canon = _canon(l);
|
||||
if (!out.any((e) => _canon(e) == canon)) {
|
||||
out.add(l);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String _canon(Locale l) {
|
||||
final country = l.countryCode;
|
||||
if (country == null || country.isEmpty) return l.languageCode;
|
||||
return '${l.languageCode}_$country';
|
||||
}
|
||||
|
||||
/// Canonical filename suffix for a locale, matching fframe: `en_us`
|
||||
/// (lowercase, country only when present).
|
||||
static String filenameSuffix(Locale l) {
|
||||
final lang = l.languageCode.toLowerCase();
|
||||
final country = l.countryCode?.toLowerCase();
|
||||
if (country == null || country.isEmpty) return lang;
|
||||
return '${lang}_$country';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clide/kernel/src/i18n/catalog_loader.dart';
|
||||
import 'package:clide/kernel/src/i18n/fallback_chain.dart';
|
||||
import 'package:clide/kernel/src/log.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class I18nReplacer {
|
||||
const I18nReplacer({required this.from, required this.replace});
|
||||
final String from;
|
||||
final String replace;
|
||||
}
|
||||
|
||||
/// Text-driven i18n — fframe-style. Keys are strings, lookups are by
|
||||
/// `(namespace, key)`, missing keys fall back through a locale chain
|
||||
/// and finally to the caller-supplied placeholder.
|
||||
///
|
||||
/// Singleton-per-kernel: `kernel.i18n`. Extensions write:
|
||||
/// final t = ctx.i18n;
|
||||
/// t.string('key', placeholder: '...', namespace: ext.id);
|
||||
class I18n extends ChangeNotifier {
|
||||
I18n({
|
||||
required this.loader,
|
||||
required this.log,
|
||||
required Locale defaultLocale,
|
||||
Locale? initialLocale,
|
||||
List<Locale> availableLocales = const [Locale('en', 'US')],
|
||||
}) : _defaultLocale = defaultLocale,
|
||||
_current = initialLocale ?? defaultLocale,
|
||||
_available = List<Locale>.unmodifiable(availableLocales);
|
||||
|
||||
final CatalogLoader loader;
|
||||
final Logger log;
|
||||
|
||||
final Locale _defaultLocale;
|
||||
Locale _current;
|
||||
final List<Locale> _available;
|
||||
|
||||
/// namespace -> locale -> flat key map
|
||||
final Map<String, Map<Locale, Map<String, Object?>>> _cache = {};
|
||||
|
||||
/// Keys we've already warned about for a given (namespace, key, locale).
|
||||
/// Keeps the log quiet across repeated lookups.
|
||||
final Set<String> _warnedMisses = {};
|
||||
|
||||
Locale get currentLocale => _current;
|
||||
Locale get defaultLocale => _defaultLocale;
|
||||
List<Locale> get availableLocales => _available;
|
||||
|
||||
/// Register a catalog that was loaded outside of [loader] — e.g. by the
|
||||
/// ExtensionManager when a third-party extension activates.
|
||||
void registerCatalog(
|
||||
String namespace,
|
||||
Locale locale,
|
||||
Map<String, Object?> catalog,
|
||||
) {
|
||||
_cache.putIfAbsent(
|
||||
namespace, () => <Locale, Map<String, Object?>>{})[locale] = catalog;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Remove every entry for a namespace (extension deactivated).
|
||||
void unregisterCatalog(String namespace) {
|
||||
if (_cache.remove(namespace) != null) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the current locale and reload every already-cached namespace
|
||||
/// for the new chain. Listeners fire once at the end.
|
||||
Future<void> setLocale(Locale locale) async {
|
||||
if (locale == _current) return;
|
||||
_current = locale;
|
||||
_warnedMisses.clear();
|
||||
for (final ns in _cache.keys.toList()) {
|
||||
await _ensureLoaded(ns);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Eagerly load a namespace across the whole fallback chain. Safe to
|
||||
/// call more than once (subsequent calls only fill missing locales).
|
||||
Future<void> ensureNamespaceLoaded(String namespace) async {
|
||||
await _ensureLoaded(namespace);
|
||||
}
|
||||
|
||||
Future<void> _ensureLoaded(String namespace) async {
|
||||
final byLocale = _cache.putIfAbsent(
|
||||
namespace,
|
||||
() => <Locale, Map<String, Object?>>{},
|
||||
);
|
||||
final chain = FallbackChain(
|
||||
current: _current,
|
||||
defaultLocale: _defaultLocale,
|
||||
).resolve();
|
||||
for (final l in chain) {
|
||||
if (byLocale.containsKey(l)) continue;
|
||||
byLocale[l] = await loader.load(namespace, l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a key, walking the locale fallback chain. Returns the
|
||||
/// placeholder if nothing hits; returns the key itself when placeholder
|
||||
/// is null (developer fallback — keys are more useful than blanks).
|
||||
String string(
|
||||
String key, {
|
||||
required String namespace,
|
||||
String? placeholder,
|
||||
}) {
|
||||
final byLocale = _cache[namespace];
|
||||
if (byLocale == null) {
|
||||
_warnOnce(
|
||||
'$namespace::MISSING_NAMESPACE::$key',
|
||||
'i18n: namespace not registered: $namespace (key: $key)',
|
||||
);
|
||||
return placeholder ?? key;
|
||||
}
|
||||
|
||||
final chain = FallbackChain(
|
||||
current: _current,
|
||||
defaultLocale: _defaultLocale,
|
||||
).resolve();
|
||||
|
||||
for (final locale in chain) {
|
||||
final catalog = byLocale[locale];
|
||||
if (catalog == null) continue;
|
||||
final hit = _extract(catalog, key);
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
|
||||
_warnOnce(
|
||||
'$namespace::${_current.languageCode}::$key',
|
||||
'i18n: missing key "$key" in namespace "$namespace" (locale ${_current.toString()})',
|
||||
);
|
||||
return placeholder ?? key;
|
||||
}
|
||||
|
||||
/// [string] + naive `replaceAll` interpolation per replacer.
|
||||
/// Matches fframe: replacers whose [from] isn't present are silent no-ops.
|
||||
String interpolated(
|
||||
String key, {
|
||||
required String namespace,
|
||||
String? placeholder,
|
||||
List<I18nReplacer> replacers = const [],
|
||||
}) {
|
||||
var out = string(key, namespace: namespace, placeholder: placeholder);
|
||||
for (final r in replacers) {
|
||||
out = out.replaceAll(r.from, r.replace);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Walks fframe's nested shape: `{ "translation": "..." }`. If the
|
||||
/// value is a plain string we accept that too (forward-compat).
|
||||
String? _extract(Map<String, Object?> catalog, String key) {
|
||||
final v = catalog[key];
|
||||
if (v == null) return null;
|
||||
if (v is String) return v;
|
||||
if (v is Map && v['translation'] is String) {
|
||||
return v['translation'] as String;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _warnOnce(String dedupeKey, String message) {
|
||||
if (_warnedMisses.add(dedupeKey)) {
|
||||
log.warn('i18n', message);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user