fix(config): quarantine an unparseable config.json instead of overwriting it (#537) (#565)

* fix(config): quarantine an unparseable config.json instead of overwriting it (#537)

A config.json that failed to parse was replaced by in-memory defaults with
only a log line, and the next write of any setting — a dragged sidebar
divider, Ctrl+=, anything saved in Settings — serialized those defaults over
the file wholesale: one typo, and every hand edit was gone. views.json and
machine.json already kept a corrupt file aside for exactly this reason;
config.json was the one that did not.

A failed parse now parks the file's contents as config.json.corrupt and
hands back defaults marked quarantined — a non-serialized flag that makes
Config::save refuse to run, so all twenty-plus write call sites are covered
without touching them. Config::load_with_outcome returns the verdict beside
the values (Parsed / Absent / Quarantined) so the hot-reload watcher can
tell a broken file from a missing one: a broken one keeps the settings the
app is running on instead of swapping defaults in, and says so in a toast;
a startup on defaults after a quarantine says so too. A read failure (not
merely a parse failure) warned nowhere at all — it logs, and suppresses
writes the same way, without parking a copy there may be nothing readable
to take.

* fix(config): keep one copy of a broken config, and one word about it

`Config::load` runs on every pane spawn and every palette command, so a
file left unparseable was quarantined again and again: opening a couple
of tabs filled the config directory with eight identical .corrupt files
and then overwrote the first. A copy that already holds those bytes is
the copy the call would make, so it is not made again.

The hot-reload watcher covers the themes directory too, and returning
early on a broken config.json took theme hot-reload down with it and
re-announced the same breakage on every theme save. Themes now reload
either way, and the toast speaks once per breakage.

A file that cannot be read parks nothing, so it no longer reports itself
as quarantined and no longer sends the user after a .corrupt file that
was never written; and the reload toast no longer claims settings stop
saving, which is true at startup but not mid-session, where the running
config is kept and stays writable.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
Hongwei Qin
2026-08-13 09:04:03 +08:00
committed by GitHub
co-authored by l0ng-ai
parent d343fd8a13
commit 14ab96284c
8 changed files with 407 additions and 17 deletions
+14
View File
@@ -32,6 +32,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
so a path a build tool printed from the workspace root did not resolve from a
member directory. A path that matches nothing under either now says so
instead of the click doing nothing.
- **A broken config.json can no longer be silently replaced by defaults** —
a file that failed to parse was ignored with only a log line, and the next
write of any setting — dragging the sidebar divider, zooming the font with
Ctrl+=, saving anything in Settings — serialized the in-memory defaults
over it wholesale, turning one typo into the loss of every hand edit. A
load that fails to parse now keeps the file's contents beside it as
`config.json.corrupt` (the same quarantine `views.json` already had), and
the stand-in defaults carry a mark that makes `save` refuse to run, so
nothing writes until the file parses again. The hot-reload watcher tells
"broken" apart from "absent" and keeps the settings the app is running on
instead of swapping defaults in mid-session, and both the startup and the
reload path say what happened and where the copy is. A file that simply
cannot be *read* logs a warning now too — it used to fall to defaults with
no trace at all. (#537)
## [26.8.3] - 2026-08-12
+271 -10
View File
@@ -298,6 +298,15 @@ pub struct Config {
/// as their history mysteriously forgetting the other window.
#[serde(default)]
pub per_pane_history: bool,
/// This instance is the stand-in for a file that could not be read or
/// parsed: `load` kept a copy aside and handed back defaults. Never
/// serialized — it describes how the file *load* went, not a setting —
/// and it makes [`Config::save`] refuse to run, because writing these
/// defaults back over the user's hand-edited file is how one typo becomes
/// permanent data loss (#537). Cleared only by a load that parses.
#[serde(skip)]
pub quarantined: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
@@ -574,29 +583,94 @@ impl Default for Config {
agent_commands: HashMap::new(),
restore_agent_sessions: true,
per_pane_history: false,
quarantined: false,
}
}
}
/// How the file behind a [`Config::load_with_outcome`] went — the answer a
/// hot-reload watcher needs before it swaps a running app onto the result,
/// because "no file yet" and "a broken file" must not do the same thing
/// (#537).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadOutcome {
/// The file parsed (after the usual field-level leniency).
Parsed,
/// There is no file (or no config dir yet): the defaults simply are the
/// config, and saving them is fine.
Absent,
/// The file existed but did not parse. A copy was kept beside it, and the
/// returned config is the defaults with writes suppressed — saving over
/// the broken file would make one typo permanent.
Quarantined,
/// The file is there but could not be read at all. Writes are suppressed
/// the same way, but nothing was parked beside it: there was nothing
/// readable to copy. Distinct from [`LoadOutcome::Quarantined`] because
/// telling the user to look in `config.json.corrupt` for contents that
/// were never written there sends them after a file that is not there.
Unreadable,
}
impl LoadOutcome {
/// Whether the file is standing between the user and their settings: the
/// values handed back are defaults with writes suppressed, not anything
/// the user wrote.
pub fn failed(self) -> bool {
matches!(self, Self::Quarantined | Self::Unreadable)
}
}
impl Config {
pub fn load() -> Self {
Self::load_with_outcome().0
}
/// [`Config::load`] with the verdict the file earned. Most callers want
/// the values either way and use `load`; the watcher that swaps a running
/// app onto the result needs the outcome to keep a broken file from
/// evicting the settings the app is running on.
pub fn load_with_outcome() -> (Self, LoadOutcome) {
let Some(path) = Self::path() else {
return Config::default();
return (Config::default(), LoadOutcome::Absent);
};
let Ok(text) = std::fs::read_to_string(&path) else {
return Config::default();
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return (Config::default(), LoadOutcome::Absent);
}
Err(e) => {
// Unreadable is not unparseable, but the rule is the same:
// what cannot be read must not be overwritten. There may be
// nothing readable to keep a copy of, so nothing is parked —
// the file itself is still where the user left it.
log::warn!(
"failed to read config at {}: {e}; using defaults, writes suppressed",
path.display()
);
let mut cfg = Config::default();
cfg.quarantined = true;
return (cfg, LoadOutcome::Unreadable);
}
};
match serde_json::from_str::<Config>(strip_bom(&text)) {
Ok(mut cfg) => {
cfg.sanitize();
cfg
(cfg, LoadOutcome::Parsed)
}
Err(e) => {
// The next `save` overwrites this file wholesale, so handing
// back defaults with no trace quietly discards whatever the
// file held the moment anything — a dragged sidebar divider —
// writes. Park a copy first, the way `WindowViews::load`
// does, and mark the stand-in so `save` refuses to run for it.
log::warn!(
"failed to parse config at {}: {e}; using defaults",
"failed to parse config at {}: {e}; keeping it aside and using defaults",
path.display()
);
Config::default()
quarantine(&path);
let mut cfg = Config::default();
cfg.quarantined = true;
(cfg, LoadOutcome::Quarantined)
}
}
}
@@ -670,6 +744,14 @@ impl Config {
}
pub fn save(&self) {
if self.quarantined {
// The file this instance stands in for could not be read, so what
// the user wrote is still on disk — writing these defaults over it
// is the wholesale loss #537 is about. The fix is to repair the
// file; the next load that parses produces a writable config.
log::warn!("not saving over a config file that failed to load; fix or remove it first");
return;
}
let Some(path) = Self::path() else {
return;
};
@@ -746,6 +828,17 @@ pub fn strip_bom(text: &str) -> &str {
/// Sets a corrupt state file aside (copied, the original left in place) so the
/// caller can fall back to defaults without silently destroying what was there.
pub(crate) fn quarantine(path: &std::path::Path) {
// A broken file is read again and again — `Config::load` alone runs on
// every pane spawn and every palette command — so this is reached over
// and over for the same contents. A sibling already holding those bytes
// *is* the copy this call would make; without the check, opening a
// couple of tabs on a broken config.json fills the config directory with
// eight identical `.corrupt` files and then overwrites the first one.
if let Ok(bytes) = std::fs::read(path)
&& already_kept(path, &bytes)
{
return;
}
let aside = quarantine_path(path);
match std::fs::copy(path, &aside) {
Ok(_) => log::warn!("the previous contents were kept at {}", aside.display()),
@@ -753,6 +846,11 @@ pub(crate) fn quarantine(path: &std::path::Path) {
}
}
/// Whether an earlier quarantine of `path` already holds exactly `bytes`.
fn already_kept(path: &std::path::Path, bytes: &[u8]) -> bool {
quarantine_candidates(path).any(|kept| std::fs::read(&kept).is_ok_and(|held| held == bytes))
}
/// Like [`quarantine`], but moves the file out of the way — for files that
/// cannot even be read, where copying would fail too.
pub(crate) fn quarantine_by_rename(path: &std::path::Path) {
@@ -763,15 +861,21 @@ pub(crate) fn quarantine_by_rename(path: &std::path::Path) {
}
}
fn quarantine_path(path: &std::path::Path) -> PathBuf {
/// Every name a quarantined copy of `path` may go under, oldest first.
fn quarantine_candidates(path: &std::path::Path) -> impl Iterator<Item = PathBuf> + use<'_> {
const MAX_QUARANTINED: u32 = 8;
let base = path.with_extension("json.corrupt");
std::iter::once(path.with_extension("json.corrupt"))
.chain((1..MAX_QUARANTINED).map(|n| path.with_extension(format!("json.corrupt.{n}"))))
}
fn quarantine_path(path: &std::path::Path) -> PathBuf {
let mut candidates = quarantine_candidates(path);
let base = candidates.next().expect("the base name is always offered");
if !base.exists() {
return base;
}
(1..MAX_QUARANTINED)
.map(|n| path.with_extension(format!("json.corrupt.{n}")))
candidates
.find(|candidate| !candidate.exists())
.unwrap_or(base)
}
@@ -1592,6 +1696,163 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_corrupt_config_is_kept_aside_and_never_overwritten() {
let _guard = lock_config_file();
pin_config_dir();
let path = Config::path().expect("pinned config dir");
let aside = path.with_extension("json.corrupt");
clear_quarantines(&path);
std::fs::write(&path, "{ not json").unwrap();
let (loaded, outcome) = Config::load_with_outcome();
assert_eq!(outcome, LoadOutcome::Quarantined);
assert!(loaded.quarantined, "the stand-in must say what it is");
assert_eq!(
std::fs::read_to_string(&aside).as_deref().ok(),
Some("{ not json"),
"the next save overwrites config.json wholesale, so the old \
contents must already be parked beside it"
);
// The save every sidebar drag issues must not turn one typo into
// permanent loss: a quarantined config refuses to write, and the file
// on disk stays exactly the user's own.
loaded.save();
assert_eq!(
std::fs::read_to_string(&path).as_deref().ok(),
Some("{ not json"),
"a quarantined config must never overwrite the file it stood in for"
);
// Fixing the file is what re-arms writes.
std::fs::write(&path, r#"{"font_size": 19.0}"#).unwrap();
let (fixed, outcome) = Config::load_with_outcome();
assert_eq!(outcome, LoadOutcome::Parsed);
assert!(!fixed.quarantined);
fixed.save();
assert!(std::fs::read_to_string(&path).unwrap().contains("19.0"));
std::fs::remove_file(&path).ok();
clear_quarantines(&path);
}
#[test]
fn a_config_that_stays_broken_is_parked_once_not_once_per_read() {
let _guard = lock_config_file();
pin_config_dir();
let path = Config::path().expect("pinned config dir");
clear_quarantines(&path);
std::fs::write(&path, "{ not json").unwrap();
// `Config::load` runs on every pane spawn and every palette command,
// so a file left broken is read dozens of times a session. Each read
// used to leave another copy, filling the config directory and then
// overwriting the oldest one.
for _ in 0..12 {
let _ = Config::load();
}
let parked: Vec<_> = quarantine_candidates(&path)
.filter(|candidate| candidate.exists())
.collect();
assert_eq!(
parked.len(),
1,
"one broken file, one copy — found {parked:?}"
);
// A *different* broken version is still worth keeping.
std::fs::write(&path, "{ also not json").unwrap();
let _ = Config::load();
let parked: Vec<_> = quarantine_candidates(&path)
.filter(|candidate| candidate.exists())
.collect();
assert_eq!(parked.len(), 2, "found {parked:?}");
assert_eq!(
std::fs::read_to_string(&parked[0]).unwrap(),
"{ not json",
"the first rescue copy is still the first one"
);
std::fs::remove_file(&path).ok();
clear_quarantines(&path);
}
#[cfg(unix)]
#[test]
fn an_unreadable_config_suppresses_writes_without_parking_a_copy() {
use std::os::unix::fs::PermissionsExt as _;
let _guard = lock_config_file();
pin_config_dir();
let path = Config::path().expect("pinned config dir");
clear_quarantines(&path);
std::fs::write(&path, r#"{"font_size": 21.0}"#).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(&path).is_ok() {
std::fs::remove_file(&path).ok();
return;
}
let (loaded, outcome) = Config::load_with_outcome();
// Not `Quarantined`: nothing was parked, so the notification must not
// point at a `config.json.corrupt` that was never written.
assert_eq!(outcome, LoadOutcome::Unreadable);
assert!(outcome.failed());
assert!(
loaded.quarantined,
"what cannot be read must not be written"
);
assert!(
quarantine_candidates(&path).all(|candidate| !candidate.exists()),
"there was nothing readable to copy"
);
loaded.save();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
r#"{"font_size": 21.0}"#,
"the file the app could not read is the file the user still has"
);
std::fs::remove_file(&path).ok();
}
fn clear_quarantines(path: &std::path::Path) {
for candidate in quarantine_candidates(path) {
std::fs::remove_file(candidate).ok();
}
}
#[test]
fn a_missing_config_file_is_absent_not_quarantined() {
let _guard = lock_config_file();
pin_config_dir();
let path = Config::path().expect("pinned config dir");
std::fs::remove_file(&path).ok();
let (loaded, outcome) = Config::load_with_outcome();
assert_eq!(outcome, LoadOutcome::Absent);
// A first run saves its defaults without anyone calling that loss.
assert!(!loaded.quarantined);
}
#[test]
fn the_quarantined_flag_never_reaches_disk() {
let cfg = Config {
quarantined: true,
..Config::default()
};
let text = serde_json::to_string(&cfg).unwrap();
assert!(!text.contains("quarantined"));
// And a hand-written `"quarantined": true` in the file does not
// suppress saves either — the flag belongs to the loader, not the file.
let parsed: Config =
serde_json::from_str(r#"{"quarantined": true, "font_size": 20.0}"#).unwrap();
assert!(!parsed.quarantined);
assert_eq!(parsed.font_size, 20.0);
}
#[test]
fn strip_bom_only_removes_a_leading_marker() {
assert_eq!(strip_bom("{}"), "{}");
+7 -2
View File
@@ -9,9 +9,14 @@ impl gpui::Global for Config {}
impl Config {
pub fn load() -> Self {
Self::load_with_outcome().0
}
pub fn load_with_outcome() -> (Self, LoadOutcome) {
#[cfg(test)]
assert_scratch_config_dir("Config::load");
Self(CoreConfig::load())
assert_scratch_config_dir("Config::load_with_outcome");
let (core, outcome) = CoreConfig::load_with_outcome();
(Self(core), outcome)
}
#[cfg(test)]
+75 -5
View File
@@ -74,17 +74,41 @@ fn spawn_config_watcher(cx: &mut App) {
Box::leak(Box::new(watcher));
cx.spawn(async move |cx| {
// One toast per breakage rather than one per write: this watcher also
// fires for every theme file, so a config.json left broken would
// otherwise re-announce itself on each of them. Cleared by the load
// that parses, so a second breakage speaks up again.
let mut announced = false;
while rx.recv().await.is_ok() {
cx.background_executor().timer(DEBOUNCE).await;
while rx.try_recv().is_ok() {}
cx.update(|cx| {
let config = Config::load();
let (config, outcome) = Config::load_with_outcome();
if outcome.failed() {
// Keep the settings the app is running on: swapping the
// stand-in defaults in would flash the whole UI onto
// defaults, and the load already parked the broken file
// beside the original. It reloads itself the moment the
// file parses again.
if !announced {
announced = true;
notify_config_load_failed(cx, outcome, false);
}
// The theme files this same watcher covers must keep
// hot-reloading: a typo in config.json is no reason for
// theme editing to go dead until the app restarts. They
// read the global config, which is deliberately still the
// one the app is running on.
reload_themes(cx);
cx.refresh_windows();
return;
}
announced = false;
crate::ui::i18n::set_locale(&config.gui_language);
cx.set_global(config);
crate::ui::presets::load_registry(cx);
reload_themes(cx);
crate::ui::theme::apply_cursor_hide_mode(cx);
crate::ui::theme::apply_theme(None, cx);
// The menu bar is built once from the current locale, so editing
// gui_language by hand has to rebuild it the same way the
// in-app language picker does.
@@ -110,6 +134,47 @@ fn is_theme_file(p: &std::path::Path) -> bool {
})
}
/// Re-reads what the theme files on disk say. The config watcher covers the
/// themes directory too, so this has to run even when config.json itself did
/// not load — editing a theme cannot go dead because of a typo elsewhere.
fn reload_themes(cx: &mut App) {
crate::ui::presets::load_registry(cx);
crate::ui::theme::apply_theme(None, cx);
}
/// Says out loud that config.json did not load and, when there is one, where
/// its contents were parked. Without this the symptom is "my settings are
/// gone" (startup) or "my edit did nothing" (reload) — both read as data loss,
/// and neither points at the file that needs fixing.
fn notify_config_load_failed(
cx: &mut App,
outcome: crate::core::config::LoadOutcome,
startup: bool,
) {
use crate::core::config::LoadOutcome;
use crate::ui::i18n::L10nKey;
use gpui_component::WindowExt as _;
// Only an unparseable file leaves a copy behind; an unreadable one had
// nothing to copy, so it must not send the user after a `.corrupt` file
// that was never written.
let key = match (outcome, startup) {
(LoadOutcome::Unreadable, true) => L10nKey::ConfigUnreadableStartup,
(LoadOutcome::Unreadable, false) => L10nKey::ConfigUnreadableReload,
(_, true) => L10nKey::ConfigQuarantinedStartup,
(_, false) => L10nKey::ConfigQuarantinedReload,
};
let Some(workspace) = crate::ui::windows::WindowRegistry::most_recent(cx) else {
return;
};
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, workspace) else {
return;
};
let _ = handle.update(cx, |_, window, cx| {
window.push_notification(crate::ui::i18n::t(key), cx);
});
}
fn strip_os_arg_prefix(arg: &std::ffi::OsStr, prefix: &str) -> Option<std::ffi::OsString> {
let suffix = arg.as_encoded_bytes().strip_prefix(prefix.as_bytes())?;
// SAFETY: `prefix` is ASCII and is removed only from the beginning of an
@@ -414,7 +479,7 @@ fn main() {
return;
}
let config = crate::core::config::Config::load();
let (config, config_outcome) = crate::core::config::Config::load_with_outcome();
let gui_language = config.gui_language.clone();
// After the PATH enrichment above, which is what makes the candidate scan
@@ -447,7 +512,9 @@ fn main() {
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
crate::ui::i18n::set_locale(&gui_language);
cx.set_global(Config::load());
// The load above is reused rather than re-read: reading the same
// file twice at launch would report the same failure twice.
cx.set_global(config);
crate::ui::theme::refresh_system_appearance(cx);
crate::core::session::WorkspaceStore::init(cx);
crate::ui::windows::WindowRegistry::init(cx);
@@ -465,6 +532,9 @@ fn main() {
let reopen = crate::ui::windows::restore_target(cx, open_path.as_deref());
crate::ui::windows::open_at(cx, reopen, open_path);
if config_outcome.failed() {
notify_config_load_failed(cx, config_outcome, true);
}
});
}
+12
View File
@@ -1424,6 +1424,18 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::AppRestartServerBody => {
"This stops every running shell on this computer — anything still running in them will be terminated. Your tabs and layout are kept and reopened with fresh shells."
}
L10nKey::ConfigQuarantinedStartup => {
"config.json could not be parsed, so tty7 is running on default settings and will not write over the file. Its contents were kept beside it as config.json.corrupt — fix the file and it reloads itself. Until then, changes made in Settings are not saved."
}
L10nKey::ConfigQuarantinedReload => {
"The edited config.json could not be parsed, so tty7 kept the settings it is already running on and set the file's contents aside as config.json.corrupt. Fix the file and it reloads itself; saving a setting before then replaces it with the settings in use."
}
L10nKey::ConfigUnreadableStartup => {
"config.json could not be read, so tty7 is running on default settings and will not write over the file — it is left exactly as it is. Fix its permissions or contents and it reloads itself. Until then, changes made in Settings are not saved."
}
L10nKey::ConfigUnreadableReload => {
"config.json could not be read, so tty7 kept the settings it is already running on and left the file exactly as it is. Fix its permissions or contents and it reloads itself; saving a setting before then replaces it with the settings in use."
}
L10nKey::AppWorktreeRemoveDetailDirty => {
"The closed tab's worktree at {path} has uncommitted changes."
}
+12
View File
@@ -1460,6 +1460,18 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::AppRestartServerBody => {
"このコンピュータで実行中のすべてのシェルが停止します。タブとレイアウトは保持され、新しいシェルで開きます"
}
L10nKey::ConfigQuarantinedStartup => {
"config.json を解析できなかったため、デフォルト設定で実行しており、ファイルを上書きすることもありません。内容は config.json.corrupt として残しました——修正すれば自動で再読み込みされます。それまでは設定での変更は保存されません"
}
L10nKey::ConfigQuarantinedReload => {
"編集された config.json を解析できなかったため、実行中の設定をそのまま保持し、ファイルの内容は config.json.corrupt として残しました。修正すれば自動で再読み込みされます。それまでに設定を保存すると、実行中の設定で上書きされます"
}
L10nKey::ConfigUnreadableStartup => {
"config.json を読み込めなかったため、デフォルト設定で実行しており、ファイルを上書きすることもありません——ファイルはそのままです。権限か内容を直せば自動で再読み込みされます。それまでは設定での変更は保存されません"
}
L10nKey::ConfigUnreadableReload => {
"config.json を読み込めなかったため、実行中の設定をそのまま保持し、ファイルもそのままにしてあります。権限か内容を直せば自動で再読み込みされます。それまでに設定を保存すると、実行中の設定で上書きされます"
}
L10nKey::AppWorktreeRemoveDetailDirty => {
"閉じたタブの {path} にあるワークツリーには未コミットの変更があります"
}
+4
View File
@@ -1168,6 +1168,10 @@ l10n_keys! {
AppRestart,
AppRestartServerNoServer,
AppRestartServerBody,
ConfigQuarantinedStartup,
ConfigQuarantinedReload,
ConfigUnreadableStartup,
ConfigUnreadableReload,
AppWorktreeRemoveDetailDirty,
AppWorktreeRemoveDetailClean,
AppWorktreeRemoveTitle,
+12
View File
@@ -1339,6 +1339,18 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::AppRestartServerBody => {
"这会停止本机上所有正在运行的 shell——其中仍在运行的任何内容都会被终止。你的标签页和布局会被保留,并以全新的 shell 重新打开。"
}
L10nKey::ConfigQuarantinedStartup => {
"config.json 无法解析,tty7 正以默认设置运行,也不会覆写该文件。原内容已保留为旁边的 config.json.corrupt——修好文件后会自动重载;在此之前,设置里的更改不会被保存。"
}
L10nKey::ConfigQuarantinedReload => {
"修改后的 config.json 无法解析,已保留当前在用的设置,文件内容也已另存为旁边的 config.json.corrupt。修好文件后会自动重载;在此之前,任何一次保存设置都会用当前在用的设置覆盖它。"
}
L10nKey::ConfigUnreadableStartup => {
"config.json 读取失败,tty7 正以默认设置运行,也不会覆写该文件——文件原样保留。修好它的权限或内容后会自动重载;在此之前,设置里的更改不会被保存。"
}
L10nKey::ConfigUnreadableReload => {
"config.json 读取失败,已保留当前在用的设置,文件也原样保留。修好它的权限或内容后会自动重载;在此之前,任何一次保存设置都会用当前在用的设置覆盖它。"
}
L10nKey::AppWorktreeRemoveDetailDirty => {
"位于 {path} 的已关闭标签页的 worktree 有未提交的变更。"
}