@mateusbmp/backup-manager 0.1.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -899,8 +899,11 @@ function useBackHandler(handler) {
899
899
  }
900
900
  function useHints(hints, deps = []) {
901
901
  const { setHints } = useApp();
902
+ const ref = useRef(hints);
903
+ ref.current = hints;
902
904
  useEffect(() => {
903
- setHints(hints);
905
+ if (!hints) return;
906
+ setHints(hints.map((h, i) => h.run ? { ...h, run: () => ref.current?.[i]?.run?.() } : h));
904
907
  }, [setHints, ...deps]);
905
908
  }
906
909
  function Provider({ value, children }) {
@@ -1012,6 +1015,9 @@ function Muted({ children }) {
1012
1015
  const { theme: theme2 } = useApp();
1013
1016
  return /* @__PURE__ */ jsx3(Text2, { color: theme2.muted, children });
1014
1017
  }
1018
+ function FormTip() {
1019
+ return /* @__PURE__ */ jsx3(Muted, { children: "Ctrl+\u2193 (or Alt+\u2193) reaches the buttons below; Ctrl-S also saves." });
1020
+ }
1015
1021
  function ErrorLine({ children }) {
1016
1022
  const { theme: theme2 } = useApp();
1017
1023
  return /* @__PURE__ */ jsxs2(Text2, { color: theme2.error, children: [
@@ -1150,12 +1156,31 @@ function Footer({
1150
1156
  theme: theme2,
1151
1157
  columns,
1152
1158
  hints,
1153
- global
1159
+ global,
1160
+ focused = false,
1161
+ selected = 0
1154
1162
  }) {
1155
- return /* @__PURE__ */ jsxs3(Box3, { width: columns, children: [
1156
- /* @__PURE__ */ jsx4(KeyHints, { hints: [...hints, ...global] }),
1157
- /* @__PURE__ */ jsx4(Text3, { color: theme2.muted, children: " " })
1158
- ] });
1163
+ if (!focused)
1164
+ return /* @__PURE__ */ jsxs3(Box3, { width: columns, children: [
1165
+ /* @__PURE__ */ jsx4(KeyHints, { hints: [...hints, ...global] }),
1166
+ /* @__PURE__ */ jsx4(Text3, { color: theme2.muted, children: " " })
1167
+ ] });
1168
+ const buttons = [...hints, ...global].filter((h) => h.run);
1169
+ return /* @__PURE__ */ jsx4(Box3, { width: columns, children: /* @__PURE__ */ jsxs3(Text3, { wrap: "truncate-end", children: [
1170
+ buttons.map((b, i) => /* @__PURE__ */ jsxs3(Text3, { children: [
1171
+ " ",
1172
+ /* @__PURE__ */ jsx4(
1173
+ Text3,
1174
+ {
1175
+ color: i === selected ? theme2.focus : theme2.accent,
1176
+ inverse: i === selected,
1177
+ bold: i === selected,
1178
+ children: `[ ${b.label} ]`
1179
+ }
1180
+ )
1181
+ ] }, `${b.key}-${b.label}`)),
1182
+ /* @__PURE__ */ jsx4(Text3, { color: theme2.muted, children: " \u2190\u2192 pick \u23CE press esc back" })
1183
+ ] }) });
1159
1184
  }
1160
1185
  var SIDEBAR_WIDTH, WIDE;
1161
1186
  var init_Frame = __esm({
@@ -1273,10 +1298,12 @@ var init_Overlay = __esm({
1273
1298
  ["PgUp PgDn", "page"],
1274
1299
  ["\u23CE", "open / select"],
1275
1300
  ["Esc", "back"],
1301
+ ["Ctrl/Alt + \u2190 \u2192 \u2193", "zones: sidebar \xB7 content \xB7 buttons (\u2191 back)"],
1302
+ ["\u2190\u2192 \u23CE (buttons)", "pick and press a button: save, add, run\u2026"],
1276
1303
  ["Tab", "switch sidebar / content (next field in forms)"],
1277
1304
  ["1\u20139", "jump to a menu entry"],
1278
1305
  ["n \xB7 e \xB7 s", "new backup \xB7 environments \xB7 service"],
1279
- ["Ctrl-S", "save a form"],
1306
+ ["Ctrl-S", "save a form (or its save button)"],
1280
1307
  ["?", "this help"],
1281
1308
  ["q", "quit (jobs keep running)"]
1282
1309
  ];
@@ -1358,7 +1385,9 @@ function useListNav(count, options) {
1358
1385
  },
1359
1386
  { isActive: options.isActive }
1360
1387
  );
1361
- return { index: Math.min(index, Math.max(0, count - 1)), setIndex };
1388
+ const at = Math.min(index, Math.max(0, count - 1));
1389
+ const select = () => count > 0 && options.onSelect?.(at);
1390
+ return { index: at, setIndex, select };
1362
1391
  }
1363
1392
  function splitList(text) {
1364
1393
  const out = [];
@@ -1375,12 +1404,16 @@ function splitList(text) {
1375
1404
  out.push(current);
1376
1405
  return out.map((s) => s.trim()).filter(Boolean);
1377
1406
  }
1378
- var isUp, isDown, joinList;
1407
+ var isZoneKey, plain, isUp, isDown, joinList;
1379
1408
  var init_keys = __esm({
1380
1409
  "src/ui/keys.ts"() {
1381
1410
  "use strict";
1382
- isUp = (input, key) => key.upArrow || input === "k";
1383
- isDown = (input, key) => key.downArrow || input === "j";
1411
+ isZoneKey = (key) => Boolean(
1412
+ (key.ctrl || key.meta) && (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)
1413
+ );
1414
+ plain = (key) => !key.ctrl && !key.meta;
1415
+ isUp = (input, key) => plain(key) && (key.upArrow || input === "k");
1416
+ isDown = (input, key) => plain(key) && (key.downArrow || input === "j");
1384
1417
  joinList = (items) => items.join(", ");
1385
1418
  }
1386
1419
  });
@@ -1492,19 +1525,22 @@ function Backups({ envName }) {
1492
1525
  if (b && env) navigate({ name: "backup-detail", env: env.name, id: b.id });
1493
1526
  }
1494
1527
  });
1528
+ const shiftEnv = (delta) => setEnvIndex((i) => (i + delta + envs.length) % Math.max(1, envs.length));
1529
+ const nextFilter = () => setFilter((f) => (f + 1) % FILTERS.length);
1495
1530
  useHints(
1496
1531
  [
1497
- { key: "\u23CE", label: "details" },
1498
- { key: "\u2190\u2192", label: "environment" },
1499
- { key: "f", label: `filter: ${status}` }
1532
+ { key: "\u23CE", label: "details", run: () => nav.select() },
1533
+ { key: "\u2190\u2192", label: "environment", run: () => shiftEnv(1) },
1534
+ { key: "f", label: `filter: ${status}`, run: nextFilter }
1500
1535
  ],
1501
1536
  [status]
1502
1537
  );
1503
1538
  useInput2(
1504
1539
  (input, key) => {
1505
- if (key.leftArrow) setEnvIndex((i) => (i - 1 + envs.length) % Math.max(1, envs.length));
1506
- else if (key.rightArrow) setEnvIndex((i) => (i + 1) % Math.max(1, envs.length));
1507
- else if (input === "f") setFilter((f) => (f + 1) % FILTERS.length);
1540
+ if (isZoneKey(key)) return;
1541
+ if (key.leftArrow) shiftEnv(-1);
1542
+ else if (key.rightArrow) shiftEnv(1);
1543
+ else if (input === "f") nextFilter();
1508
1544
  },
1509
1545
  { isActive: active }
1510
1546
  );
@@ -1562,10 +1598,7 @@ function BackupDetail2({ envName, id }) {
1562
1598
  0,
1563
1599
  [config, envName, id]
1564
1600
  );
1565
- useHints([
1566
- { key: "\u2191\u2193", label: "scroll" },
1567
- { key: "esc", label: "back" }
1568
- ]);
1601
+ useHints([{ key: "\u2191\u2193", label: "scroll" }]);
1569
1602
  const b = backup.data;
1570
1603
  const lines = [];
1571
1604
  if (b) {
@@ -1855,17 +1888,17 @@ function Dashboard() {
1855
1888
  if (env) navigate({ name: "backups", env: env.name });
1856
1889
  }
1857
1890
  });
1891
+ const runNow = useRunNow(active, () => envs[nav.index]?.name);
1858
1892
  useHints(
1859
1893
  [
1860
1894
  { key: "\u2191\u2193", label: "move" },
1861
- { key: "\u23CE", label: "backups" },
1862
- { key: "r", label: "run now" },
1863
- { key: "n", label: "new backup" },
1864
- { key: "e", label: "environments" }
1895
+ { key: "\u23CE", label: "backups", run: () => nav.select() },
1896
+ { key: "r", label: "run now", run: runNow },
1897
+ { key: "n", label: "new backup", run: () => navigate({ name: "new-backup" }) },
1898
+ { key: "e", label: "environments", run: () => navigate({ name: "environments" }) }
1865
1899
  ],
1866
1900
  []
1867
1901
  );
1868
- useRunNow(active, () => envs[nav.index]?.name);
1869
1902
  if (!config) return null;
1870
1903
  if (config.environments.length === 0) {
1871
1904
  return /* @__PURE__ */ jsxs7(Panel, { title: "Welcome", width, height, children: [
@@ -1920,29 +1953,27 @@ function Dashboard() {
1920
1953
  }
1921
1954
  function useRunNow(active, envName) {
1922
1955
  const { services, config, navigate, toast, confirm } = useApp();
1923
- useInput3(
1924
- (input) => {
1925
- if (input !== "r" || !config) return;
1926
- const name2 = envName();
1927
- if (!name2) return;
1928
- void (async () => {
1929
- if (!await confirm({
1930
- title: "Run backup now",
1931
- message: `Back up every database and project of '${name2}' now?`,
1932
- confirmLabel: "Run"
1933
- }))
1934
- return;
1935
- try {
1936
- const job = await services.startBackup(config, [{ environment: name2 }]);
1937
- toast(`backup of ${name2} started`, "success");
1938
- navigate({ name: "job-live", id: job.id });
1939
- } catch (error) {
1940
- toast(error.message, "error");
1941
- }
1942
- })();
1943
- },
1944
- { isActive: active }
1945
- );
1956
+ const runNow = () => {
1957
+ const name2 = envName();
1958
+ if (!config || !name2) return;
1959
+ void (async () => {
1960
+ if (!await confirm({
1961
+ title: "Run backup now",
1962
+ message: `Back up every database and project of '${name2}' now?`,
1963
+ confirmLabel: "Run"
1964
+ }))
1965
+ return;
1966
+ try {
1967
+ const job = await services.startBackup(config, [{ environment: name2 }]);
1968
+ toast(`backup of ${name2} started`, "success");
1969
+ navigate({ name: "job-live", id: job.id });
1970
+ } catch (error) {
1971
+ toast(error.message, "error");
1972
+ }
1973
+ })();
1974
+ };
1975
+ useInput3((input) => input === "r" && runNow(), { isActive: active });
1976
+ return runNow;
1946
1977
  }
1947
1978
  var init_Dashboard = __esm({
1948
1979
  "src/ui/screens/Dashboard.tsx"() {
@@ -1967,7 +1998,7 @@ function Doctor() {
1967
1998
  const report = usePolling(() => services.doctor({ db: true }), 0);
1968
1999
  const [offset, setOffset] = useState6(0);
1969
2000
  useHints([
1970
- { key: "r", label: "re-run" },
2001
+ { key: "r", label: "re-run", run: () => report.refresh() },
1971
2002
  { key: "\u2191\u2193", label: "scroll" }
1972
2003
  ]);
1973
2004
  const color = { ok: theme2.success, warn: theme2.warning, fail: theme2.error };
@@ -2046,6 +2077,7 @@ function TextField({ value, onChange, focus, mask, placeholder, width }) {
2046
2077
  (input, key) => {
2047
2078
  const { value: v, cursor: cursor2 } = live.current;
2048
2079
  const at2 = Math.min(cursor2, v.length);
2080
+ if (isZoneKey(key)) return;
2049
2081
  if (key.leftArrow) commit(v, Math.max(0, at2 - 1));
2050
2082
  else if (key.rightArrow) commit(v, Math.min(v.length, at2 + 1));
2051
2083
  else if (key.home || key.ctrl && input === "a") commit(v, 0);
@@ -2088,6 +2120,7 @@ var init_TextField = __esm({
2088
2120
  "src/ui/components/TextField.tsx"() {
2089
2121
  "use strict";
2090
2122
  init_context();
2123
+ init_keys();
2091
2124
  }
2092
2125
  });
2093
2126
 
@@ -2123,6 +2156,7 @@ function Form(props) {
2123
2156
  };
2124
2157
  useInput6(
2125
2158
  (input, key) => {
2159
+ if (isZoneKey(key)) return;
2126
2160
  if (key.ctrl && input === "s") return props.onSubmit(latest.current);
2127
2161
  if (key.escape) return props.onCancel();
2128
2162
  if (key.tab) return move(key.shift ? -1 : 1);
@@ -2234,6 +2268,7 @@ var init_Form = __esm({
2234
2268
  "src/ui/components/Form.tsx"() {
2235
2269
  "use strict";
2236
2270
  init_context();
2271
+ init_keys();
2237
2272
  init_basics();
2238
2273
  init_TextField();
2239
2274
  focusable = (f) => f.kind !== "heading";
@@ -2323,12 +2358,6 @@ function DatabaseEdit({
2323
2358
  const [errors, setErrors] = useState9({});
2324
2359
  const [general, setGeneral] = useState9([]);
2325
2360
  const [test, setTest] = useState9(null);
2326
- useHints([
2327
- { key: "tab", label: "next field" },
2328
- { key: "\u2190\u2192", label: "change type" },
2329
- { key: "ctrl-s", label: "apply" },
2330
- { key: "esc", label: "cancel" }
2331
- ]);
2332
2361
  const validate = (values2) => {
2333
2362
  const parsed = DatabaseSchema.safeParse(buildDatabase(values2));
2334
2363
  if (!parsed.success) {
@@ -2368,6 +2397,12 @@ function DatabaseEdit({
2368
2397
  setTest({ state: "running" });
2369
2398
  void services.testConnection(env, db).then((r) => setTest({ state: "done", ...r }));
2370
2399
  };
2400
+ useHints([
2401
+ { key: "tab", label: "field" },
2402
+ { key: "\u2190\u2192", label: "change" },
2403
+ { key: "ctrl-s", label: "apply", run: () => submit(values) },
2404
+ { key: "esc", label: "cancel", run: onCancel }
2405
+ ]);
2371
2406
  const laravel = values.type === "laravel";
2372
2407
  const fields = [
2373
2408
  { kind: "text", key: "name", label: "Name", placeholder: "shop", error: errors.name },
@@ -2568,11 +2603,6 @@ function ProjectEdit({
2568
2603
  const [errors, setErrors] = useState10({});
2569
2604
  const [general, setGeneral] = useState10([]);
2570
2605
  const hint = useArtisanHint(String(values.path));
2571
- useHints([
2572
- { key: "tab", label: "next field" },
2573
- { key: "ctrl-s", label: "apply" },
2574
- { key: "esc", label: "cancel" }
2575
- ]);
2576
2606
  const submit = (values2) => {
2577
2607
  const candidate = {
2578
2608
  name: String(values2.name).trim(),
@@ -2601,6 +2631,11 @@ function ProjectEdit({
2601
2631
  );
2602
2632
  onApply({ ...env, projects, databases });
2603
2633
  };
2634
+ useHints([
2635
+ { key: "tab", label: "field" },
2636
+ { key: "ctrl-s", label: "apply", run: () => submit(values) },
2637
+ { key: "esc", label: "cancel", run: onCancel }
2638
+ ]);
2604
2639
  const fields = [
2605
2640
  {
2606
2641
  kind: "text",
@@ -2709,7 +2744,7 @@ function EnvironmentEdit({
2709
2744
  envName,
2710
2745
  onboarding
2711
2746
  }) {
2712
- const { config, width, height, save, back, navigate, confirm, services, toast, theme: theme2 } = useApp();
2747
+ const { config, width, height, save, back, navigate, confirm, services, toast } = useApp();
2713
2748
  const active = useScreenActive();
2714
2749
  const original = config && envName ? findEnvironment(config, envName) : void 0;
2715
2750
  const [draft, setDraft] = useState11(
@@ -2725,14 +2760,15 @@ function EnvironmentEdit({
2725
2760
  const [mode, setMode] = useState11({ kind: "env" });
2726
2761
  const [errors, setErrors] = useState11({});
2727
2762
  const [general, setGeneral] = useState11([]);
2763
+ const cancel = () => onboarding ? void 0 : back();
2728
2764
  useHints(
2729
2765
  mode.kind === "env" ? [
2730
2766
  { key: "tab/\u2191\u2193", label: "field" },
2731
2767
  { key: "\u23CE", label: "open" },
2732
2768
  { key: "d", label: "remove item" },
2733
- { key: "ctrl-s", label: "save" },
2734
- { key: "esc", label: "cancel" }
2735
- ] : [],
2769
+ { key: "ctrl-s", label: "save", run: () => void submit(values) },
2770
+ ...onboarding ? [] : [{ key: "esc", label: "cancel", run: cancel }]
2771
+ ] : null,
2736
2772
  [mode.kind]
2737
2773
  );
2738
2774
  if (!config) return null;
@@ -2962,7 +2998,7 @@ function EnvironmentEdit({
2962
2998
  values,
2963
2999
  onChange: setValues,
2964
3000
  onSubmit: (v) => void submit(v),
2965
- onCancel: () => onboarding ? void 0 : back(),
3001
+ onCancel: cancel,
2966
3002
  isActive: active,
2967
3003
  width: width - 4,
2968
3004
  height: height - 4 - general.length
@@ -2970,7 +3006,7 @@ function EnvironmentEdit({
2970
3006
  ),
2971
3007
  /* @__PURE__ */ jsxs13(Box12, { flexDirection: "column", children: [
2972
3008
  general.map((g) => /* @__PURE__ */ jsx14(ErrorLine, { children: g }, g)),
2973
- onboarding && /* @__PURE__ */ jsx14(Text13, { color: theme2.muted, children: "Ctrl-S saves; you can add projects and databases now or later." })
3009
+ onboarding && /* @__PURE__ */ jsx14(FormTip, {})
2974
3010
  ] })
2975
3011
  ]
2976
3012
  }
@@ -3004,34 +3040,41 @@ function Environments() {
3004
3040
  state: useSessionState("environments:index", 0),
3005
3041
  onSelect: (i) => navigate({ name: "environment-edit", env: envs[i]?.name })
3006
3042
  });
3043
+ const runNow = useRunNow(active, () => envs[nav.index]?.name);
3044
+ const add = () => navigate({ name: "environment-edit" });
3045
+ const backups = () => {
3046
+ const env = envs[nav.index];
3047
+ if (env) navigate({ name: "backups", env: env.name });
3048
+ };
3049
+ const remove = () => {
3050
+ const env = envs[nav.index];
3051
+ if (!env || !config) return;
3052
+ void (async () => {
3053
+ const ok = await confirm({
3054
+ title: "Delete environment",
3055
+ message: `Remove '${env.name}' from the config? Its backups on disk are kept.`,
3056
+ confirmLabel: "Delete",
3057
+ danger: true
3058
+ });
3059
+ if (ok)
3060
+ await save(
3061
+ { ...config, environments: envs.filter((e) => e.name !== env.name) },
3062
+ `deleted ${env.name}`
3063
+ );
3064
+ })();
3065
+ };
3007
3066
  useHints([
3008
- { key: "\u23CE", label: "edit" },
3009
- { key: "a", label: "add" },
3010
- { key: "d", label: "delete" },
3011
- { key: "b", label: "backups" },
3012
- { key: "r", label: "run now" }
3067
+ { key: "\u23CE", label: "edit", run: () => nav.select() },
3068
+ { key: "a", label: "add", run: add },
3069
+ { key: "d", label: "delete", run: remove },
3070
+ { key: "b", label: "backups", run: backups },
3071
+ { key: "r", label: "run now", run: runNow }
3013
3072
  ]);
3014
- useRunNow(active, () => envs[nav.index]?.name);
3015
3073
  useInput7(
3016
3074
  (input) => {
3017
- const env = envs[nav.index];
3018
- if (input === "a") navigate({ name: "environment-edit" });
3019
- else if (input === "b" && env) navigate({ name: "backups", env: env.name });
3020
- else if (input === "d" && env && config) {
3021
- void (async () => {
3022
- const ok = await confirm({
3023
- title: "Delete environment",
3024
- message: `Remove '${env.name}' from the config? Its backups on disk are kept.`,
3025
- confirmLabel: "Delete",
3026
- danger: true
3027
- });
3028
- if (ok)
3029
- await save(
3030
- { ...config, environments: envs.filter((e) => e.name !== env.name) },
3031
- `deleted ${env.name}`
3032
- );
3033
- })();
3034
- }
3075
+ if (input === "a") add();
3076
+ else if (input === "b") backups();
3077
+ else if (input === "d") remove();
3035
3078
  },
3036
3079
  { isActive: active }
3037
3080
  );
@@ -3116,8 +3159,8 @@ function Jobs() {
3116
3159
  }
3117
3160
  });
3118
3161
  useHints([
3119
- { key: "\u23CE", label: "follow" },
3120
- { key: "x", label: "stop" }
3162
+ { key: "\u23CE", label: "follow", run: () => nav.select() },
3163
+ { key: "x", label: "stop", run: () => kill(list[nav.index]) }
3121
3164
  ]);
3122
3165
  useInput8((input) => input === "x" && kill(list[nav.index]), { isActive: active });
3123
3166
  return /* @__PURE__ */ jsx16(
@@ -3213,8 +3256,8 @@ function JobLive({ id }) {
3213
3256
  const max = Math.max(0, logLines.length - logHeight);
3214
3257
  useHints([
3215
3258
  { key: "\u2191\u2193", label: "scroll log" },
3216
- { key: "G", label: "follow" },
3217
- { key: "x", label: "stop" }
3259
+ { key: "G", label: "follow", run: () => setOffset(null) },
3260
+ { key: "x", label: "stop", run: () => kill(j ?? void 0) }
3218
3261
  ]);
3219
3262
  useInput8(
3220
3263
  (input, key) => {
@@ -3302,7 +3345,7 @@ function buildTargets(envs, picked, items) {
3302
3345
  return targets;
3303
3346
  }
3304
3347
  function NewBackup({ envName }) {
3305
- const { config, services, width, height, navigate, toast, theme: theme2 } = useApp();
3348
+ const { config, services, width, height, navigate, back, toast, theme: theme2 } = useApp();
3306
3349
  const active = useScreenActive();
3307
3350
  const envs = (config?.environments ?? []).filter(
3308
3351
  (e) => e.databases.length + e.projects.length > 0
@@ -3323,18 +3366,6 @@ function NewBackup({ envName }) {
3323
3366
  const [starting, setStarting] = useState13(false);
3324
3367
  const envNav = useListNav(envs.length, { isActive: active && step === "envs" });
3325
3368
  const itemNav = useListNav(items.length, { isActive: active && step === "items" });
3326
- useHints(
3327
- step === "confirm" ? [
3328
- { key: "\u23CE", label: "start" },
3329
- { key: "esc", label: "back" }
3330
- ] : [
3331
- { key: "space", label: "toggle" },
3332
- { key: "a", label: "all/none" },
3333
- { key: "\u23CE", label: "next" },
3334
- { key: "esc", label: "back" }
3335
- ],
3336
- [step]
3337
- );
3338
3369
  const start = async () => {
3339
3370
  if (!config || starting) return;
3340
3371
  setStarting(true);
@@ -3347,39 +3378,61 @@ function NewBackup({ envName }) {
3347
3378
  setStarting(false);
3348
3379
  }
3349
3380
  };
3350
- useBackHandler(() => {
3381
+ const stepBack = () => {
3351
3382
  if (step === "envs") return false;
3352
3383
  setStep(step === "confirm" ? "items" : "envs");
3353
3384
  return true;
3354
- });
3385
+ };
3386
+ useBackHandler(stepBack);
3387
+ const toggle = () => {
3388
+ if (step === "envs") {
3389
+ const env = envs[envNav.index];
3390
+ if (!env) return;
3391
+ const next2 = new Set(selectedEnvs);
3392
+ next2.has(env.name) ? next2.delete(env.name) : next2.add(env.name);
3393
+ setSelectedEnvs(next2);
3394
+ } else if (step === "items") {
3395
+ const item = items[itemNav.index];
3396
+ if (!item) return;
3397
+ const next2 = new Set(excluded);
3398
+ next2.has(keyOf(item)) ? next2.delete(keyOf(item)) : next2.add(keyOf(item));
3399
+ setExcluded(next2);
3400
+ }
3401
+ };
3402
+ const all = () => {
3403
+ if (step === "envs")
3404
+ setSelectedEnvs(
3405
+ selectedEnvs.size === envs.length ? /* @__PURE__ */ new Set() : new Set(envs.map((e) => e.name))
3406
+ );
3407
+ else if (step === "items")
3408
+ setExcluded(picked.size === items.length ? new Set(items.map(keyOf)) : /* @__PURE__ */ new Set());
3409
+ };
3410
+ const next = () => {
3411
+ if (step === "envs") {
3412
+ const env = envs[envNav.index];
3413
+ if (selectedEnvs.size === 0 && env) setSelectedEnvs(/* @__PURE__ */ new Set([env.name]));
3414
+ setStep("items");
3415
+ } else if (step === "items") {
3416
+ if (picked.size > 0) setStep("confirm");
3417
+ } else void start();
3418
+ };
3419
+ useHints(
3420
+ step === "confirm" ? [
3421
+ { key: "\u23CE", label: "start", run: next },
3422
+ { key: "esc", label: "back", run: stepBack }
3423
+ ] : [
3424
+ { key: "space", label: "toggle", run: toggle },
3425
+ { key: "a", label: "all/none", run: all },
3426
+ { key: "\u23CE", label: "next", run: next },
3427
+ { key: "esc", label: "back", run: () => stepBack() || back() }
3428
+ ],
3429
+ [step]
3430
+ );
3355
3431
  useInput9(
3356
3432
  (input, key) => {
3357
- if (step === "envs") {
3358
- const env = envs[envNav.index];
3359
- if (input === " " && env) {
3360
- const next = new Set(selectedEnvs);
3361
- next.has(env.name) ? next.delete(env.name) : next.add(env.name);
3362
- setSelectedEnvs(next);
3363
- } else if (input === "a") {
3364
- setSelectedEnvs(
3365
- selectedEnvs.size === envs.length ? /* @__PURE__ */ new Set() : new Set(envs.map((e) => e.name))
3366
- );
3367
- } else if (key.return) {
3368
- if (selectedEnvs.size === 0 && env) setSelectedEnvs(/* @__PURE__ */ new Set([env.name]));
3369
- setStep("items");
3370
- }
3371
- } else if (step === "items") {
3372
- const item = items[itemNav.index];
3373
- if (input === " " && item) {
3374
- const next = new Set(excluded);
3375
- next.has(keyOf(item)) ? next.delete(keyOf(item)) : next.add(keyOf(item));
3376
- setExcluded(next);
3377
- } else if (input === "a") {
3378
- setExcluded(picked.size === items.length ? new Set(items.map(keyOf)) : /* @__PURE__ */ new Set());
3379
- } else if (key.return && picked.size > 0) setStep("confirm");
3380
- } else if (step === "confirm") {
3381
- if (key.return) void start();
3382
- }
3433
+ if (input === " ") toggle();
3434
+ else if (input === "a") all();
3435
+ else if (key.return) next();
3383
3436
  },
3384
3437
  { isActive: active }
3385
3438
  );
@@ -3460,10 +3513,6 @@ function Onboarding() {
3460
3513
  const [values, setValues] = useState14({ backupDir: "", keepLast: "7", keepDays: "30" });
3461
3514
  const [errors, setErrors] = useState14({});
3462
3515
  const [general, setGeneral] = useState14([]);
3463
- useHints([
3464
- { key: "tab", label: "field" },
3465
- { key: "ctrl-s", label: "continue" }
3466
- ]);
3467
3516
  const submit = async (values2) => {
3468
3517
  const base = emptyConfig();
3469
3518
  const parsed = ConfigSchema.safeParse({
@@ -3506,15 +3555,12 @@ function Onboarding() {
3506
3555
  label: "Keep days",
3507
3556
  error: errors.keepDays,
3508
3557
  hint: "also keep anything younger than this many days"
3509
- },
3510
- {
3511
- kind: "action",
3512
- key: "continue",
3513
- label: "Continue \u25B8",
3514
- detail: "save and add the first environment",
3515
- onPress: () => void submit(values)
3516
3558
  }
3517
3559
  ];
3560
+ useHints([
3561
+ { key: "tab", label: "field" },
3562
+ { key: "ctrl-s", label: "continue", run: () => void submit(values) }
3563
+ ]);
3518
3564
  return /* @__PURE__ */ jsxs17(Panel, { title: "Welcome", width, height, focused: active, right: "step 1 of 2", children: [
3519
3565
  /* @__PURE__ */ jsxs17(Box15, { flexDirection: "column", marginBottom: 1, children: [
3520
3566
  /* @__PURE__ */ jsx18(GradientText, { children: "Let's set up backup-manager" }),
@@ -3540,7 +3586,8 @@ function Onboarding() {
3540
3586
  width: width - 4
3541
3587
  }
3542
3588
  ),
3543
- general.map((g) => /* @__PURE__ */ jsx18(ErrorLine, { children: g }, g))
3589
+ general.map((g) => /* @__PURE__ */ jsx18(ErrorLine, { children: g }, g)),
3590
+ /* @__PURE__ */ jsx18(Box15, { marginTop: 1, children: /* @__PURE__ */ jsx18(FormTip, {}) })
3544
3591
  ] });
3545
3592
  }
3546
3593
  var init_Onboarding = __esm({
@@ -3570,11 +3617,6 @@ function Prune() {
3570
3617
  );
3571
3618
  const [offset, setOffset] = useState15(0);
3572
3619
  const [busy, setBusy] = useState15(false);
3573
- useHints([
3574
- { key: "p", label: "apply" },
3575
- { key: "r", label: "refresh" },
3576
- { key: "\u2191\u2193", label: "scroll" }
3577
- ]);
3578
3620
  const list = plans.data ?? [];
3579
3621
  const toDelete = list.reduce((s, p) => s + p.delete.length + p.staleTemp.length, 0);
3580
3622
  const freed = list.reduce((s, p) => s + p.freedBytes, 0);
@@ -3600,31 +3642,38 @@ function Prune() {
3600
3642
  lines.push(/* @__PURE__ */ jsx19(Text18, { color: theme2.muted, children: " nothing to delete" }));
3601
3643
  }
3602
3644
  const viewHeight = height - 5;
3645
+ const apply = () => {
3646
+ if (toDelete === 0 || busy) return;
3647
+ void (async () => {
3648
+ const ok = await confirm({
3649
+ title: "Apply retention",
3650
+ message: `Delete ${toDelete} item(s) and free ${formatBytes(freed)}? This cannot be undone.`,
3651
+ confirmLabel: "Delete",
3652
+ danger: true
3653
+ });
3654
+ if (!ok) return;
3655
+ setBusy(true);
3656
+ const r = await services.applyPrune(list);
3657
+ setBusy(false);
3658
+ toast(
3659
+ r.errors.length ? `deleted ${r.deleted}; ${r.errors[0]}` : `deleted ${r.deleted} item(s)`,
3660
+ r.errors.length ? "warning" : "success"
3661
+ );
3662
+ plans.refresh();
3663
+ })();
3664
+ };
3665
+ useHints([
3666
+ { key: "p", label: "apply", run: apply },
3667
+ { key: "r", label: "refresh", run: () => plans.refresh() },
3668
+ { key: "\u2191\u2193", label: "scroll" }
3669
+ ]);
3603
3670
  useInput10(
3604
3671
  (input, key) => {
3605
3672
  if (isUp(input, key)) setOffset((o) => Math.max(0, o - 1));
3606
3673
  else if (isDown(input, key))
3607
3674
  setOffset((o) => Math.min(Math.max(0, lines.length - viewHeight), o + 1));
3608
3675
  else if (input === "r") plans.refresh();
3609
- else if (input === "p" && toDelete > 0 && !busy) {
3610
- void (async () => {
3611
- const ok = await confirm({
3612
- title: "Apply retention",
3613
- message: `Delete ${toDelete} item(s) and free ${formatBytes(freed)}? This cannot be undone.`,
3614
- confirmLabel: "Delete",
3615
- danger: true
3616
- });
3617
- if (!ok) return;
3618
- setBusy(true);
3619
- const r = await services.applyPrune(list);
3620
- setBusy(false);
3621
- toast(
3622
- r.errors.length ? `deleted ${r.deleted}; ${r.errors[0]}` : `deleted ${r.deleted} item(s)`,
3623
- r.errors.length ? "warning" : "success"
3624
- );
3625
- plans.refresh();
3626
- })();
3627
- }
3676
+ else if (input === "p") apply();
3628
3677
  },
3629
3678
  { isActive: active }
3630
3679
  );
@@ -3732,20 +3781,16 @@ function Service() {
3732
3781
  isActive: active,
3733
3782
  onSelect: (i) => void run(ACTIONS[i].action)
3734
3783
  });
3784
+ const refresh = () => {
3785
+ status.refresh();
3786
+ logs.refresh();
3787
+ };
3735
3788
  useHints([
3736
3789
  { key: "\u2191\u2193", label: "action" },
3737
- { key: "\u23CE", label: "run" },
3738
- { key: "r", label: "refresh" }
3790
+ { key: "\u23CE", label: "run", run: () => nav.select() },
3791
+ { key: "r", label: "refresh", run: refresh }
3739
3792
  ]);
3740
- useInput11(
3741
- (input) => {
3742
- if (input === "r") {
3743
- status.refresh();
3744
- logs.refresh();
3745
- }
3746
- },
3747
- { isActive: active }
3748
- );
3793
+ useInput11((input) => input === "r" && refresh(), { isActive: active });
3749
3794
  const half = Math.floor(width / 2);
3750
3795
  const tz = config ? timezone(config) : void 0;
3751
3796
  const api = config?.api;
@@ -3874,8 +3919,8 @@ function Settings() {
3874
3919
  useHints([
3875
3920
  { key: "tab", label: "field" },
3876
3921
  { key: "space", label: "toggle" },
3877
- { key: "ctrl-s", label: "save" },
3878
- { key: "esc", label: "leave" }
3922
+ { key: "ctrl-s", label: "save", run: () => void submit(values) },
3923
+ { key: "esc", label: "cancel", run: () => void leave() }
3879
3924
  ]);
3880
3925
  if (!config) return null;
3881
3926
  const submit = async (current) => {
@@ -3893,6 +3938,16 @@ function Settings() {
3893
3938
  setGeneral([]);
3894
3939
  await save(parsed.data, "settings saved");
3895
3940
  };
3941
+ const leave = async () => {
3942
+ const dirty = JSON.stringify(values) !== JSON.stringify(settingsValues(config));
3943
+ if (!dirty || await confirm({
3944
+ title: "Discard changes",
3945
+ message: "Leave Settings without saving?",
3946
+ confirmLabel: "Discard",
3947
+ danger: true
3948
+ }))
3949
+ back();
3950
+ };
3896
3951
  const fields = [
3897
3952
  { kind: "heading", key: "h1", label: "Storage" },
3898
3953
  {
@@ -3986,16 +4041,7 @@ function Settings() {
3986
4041
  values,
3987
4042
  onChange: setValues,
3988
4043
  onSubmit: (v) => void submit(v),
3989
- onCancel: () => void (async () => {
3990
- const dirty = JSON.stringify(values) !== JSON.stringify(settingsValues(config));
3991
- if (!dirty || await confirm({
3992
- title: "Discard changes",
3993
- message: "Leave Settings without saving?",
3994
- confirmLabel: "Discard",
3995
- danger: true
3996
- }))
3997
- back();
3998
- })(),
4044
+ onCancel: () => void leave(),
3999
4045
  isActive: active,
4000
4046
  width: width - 4,
4001
4047
  height: height - 3 - general.length
@@ -4107,6 +4153,12 @@ function App(props) {
4107
4153
  const [stack, setStack] = useState18([props.initialRoute ?? { name: "dashboard" }]);
4108
4154
  const route = stack[stack.length - 1];
4109
4155
  const [focus, setFocus] = useState18("content");
4156
+ const [actionIndex, setActionIndexState] = useState18(0);
4157
+ const actionRef = useRef7(0);
4158
+ const setActionIndex = (i) => {
4159
+ actionRef.current = i;
4160
+ setActionIndexState(i);
4161
+ };
4110
4162
  const [sidebarIndex, setSidebarIndex] = useState18(0);
4111
4163
  const [captures, setCaptures] = useState18([]);
4112
4164
  const [hints, setHints] = useState18([]);
@@ -4206,6 +4258,23 @@ function App(props) {
4206
4258
  const captured = captures.length > 0;
4207
4259
  const wide = size.columns >= WIDE;
4208
4260
  const current = menuIndex(route);
4261
+ const goBack = () => {
4262
+ if (backHandler.current?.()) return;
4263
+ if (stack.length > 1) back();
4264
+ else if (wide && config) {
4265
+ setSidebarIndex(current);
4266
+ setFocus("sidebar");
4267
+ }
4268
+ };
4269
+ const globals = [
4270
+ { key: "ctrl+\u2190\u2193\u2192", label: "zones" },
4271
+ // Screens that declare their own Esc button (forms: cancel) don't get a second one.
4272
+ ...stack.length > 1 && !hints.some((h) => h.key === "esc") ? [{ key: "esc", label: "back", run: goBack }] : [],
4273
+ { key: "?", label: "help", run: () => setModal({ kind: "help" }) },
4274
+ { key: "q", label: "quit", run: () => ink.exit() }
4275
+ ];
4276
+ const buttons = [...hints, ...globals].filter((h) => h.run);
4277
+ const selectedAction = Math.min(actionIndex, Math.max(0, buttons.length - 1));
4209
4278
  const closeModal = (result) => {
4210
4279
  if (modal?.kind === "confirm") modal.resolve(result ?? false);
4211
4280
  setModal(null);
@@ -4216,6 +4285,7 @@ function App(props) {
4216
4285
  if (modal.kind === "help") return closeModal();
4217
4286
  if (input === "y") return closeModal(true);
4218
4287
  if (input === "n" || key.escape) return closeModal(false);
4288
+ if (isZoneKey(key)) return;
4219
4289
  if (key.leftArrow || key.rightArrow || key.tab)
4220
4290
  return setModal({ ...modal, choice: !modal.choice });
4221
4291
  if (key.return) return closeModal(modal.choice);
@@ -4238,18 +4308,45 @@ function App(props) {
4238
4308
  return setFocus((f) => f === "content" && wide ? "sidebar" : "content");
4239
4309
  }
4240
4310
  if (key.escape) {
4241
- if (focus === "content" && backHandler.current?.()) return;
4242
- if (stack.length > 1) back();
4243
- else if (wide) {
4244
- setSidebarIndex(current);
4245
- setFocus("sidebar");
4246
- }
4311
+ if (focus === "content") goBack();
4312
+ else if (stack.length > 1) back();
4313
+ }
4314
+ },
4315
+ { isActive: !captured && modal === null && focus !== "actions" }
4316
+ );
4317
+ useInput12(
4318
+ (_input, key) => {
4319
+ if (!isZoneKey(key)) return;
4320
+ if (key.leftArrow) {
4321
+ if (!wide || !config) return;
4322
+ setSidebarIndex(current);
4323
+ setFocus("sidebar");
4324
+ } else if (key.downArrow) {
4325
+ setActionIndex(0);
4326
+ setFocus("actions");
4327
+ } else setFocus("content");
4328
+ },
4329
+ { isActive: modal === null }
4330
+ );
4331
+ useInput12(
4332
+ (input, key) => {
4333
+ if (isZoneKey(key)) return;
4334
+ const n = buttons.length;
4335
+ const at = Math.min(actionRef.current, n - 1);
4336
+ if (key.leftArrow || input === "h" || key.tab && key.shift)
4337
+ setActionIndex((at - 1 + n) % n);
4338
+ else if (key.rightArrow || input === "l" || key.tab) setActionIndex((at + 1) % n);
4339
+ else if (key.escape || key.upArrow) setFocus("content");
4340
+ else if (key.return || input === " ") {
4341
+ setFocus("content");
4342
+ buttons[at]?.run?.();
4247
4343
  }
4248
4344
  },
4249
- { isActive: !captured && modal === null }
4345
+ { isActive: focus === "actions" && modal === null && buttons.length > 0 }
4250
4346
  );
4251
4347
  useInput12(
4252
4348
  (input, key) => {
4349
+ if (isZoneKey(key)) return;
4253
4350
  if (isUp(input, key)) setSidebarIndex((i) => (i - 1 + MENU.length) % MENU.length);
4254
4351
  else if (isDown(input, key)) setSidebarIndex((i) => (i + 1) % MENU.length);
4255
4352
  else if (key.return || key.rightArrow) {
@@ -4322,7 +4419,9 @@ function App(props) {
4322
4419
  theme: theme2,
4323
4420
  columns: size.columns,
4324
4421
  hints,
4325
- global: captured ? [] : GLOBAL_HINTS
4422
+ global: captured ? globals.slice(0, 1) : globals,
4423
+ focused: focus === "actions",
4424
+ selected: selectedAction
4326
4425
  }
4327
4426
  ),
4328
4427
  /* @__PURE__ */ jsx23(Toasts, { theme: theme2, toasts, now }),
@@ -4350,7 +4449,10 @@ function QuitOnQ() {
4350
4449
  }
4351
4450
  function ConfigErrorView({ message }) {
4352
4451
  const { theme: theme2, reload, setHints } = useApp();
4353
- useEffect7(() => setHints([{ key: "r", label: "reload" }]), [setHints]);
4452
+ useEffect7(
4453
+ () => setHints([{ key: "r", label: "reload", run: () => void reload() }]),
4454
+ [setHints, reload]
4455
+ );
4354
4456
  useInput12((input) => {
4355
4457
  if (input === "r") void reload();
4356
4458
  });
@@ -4360,7 +4462,6 @@ function ConfigErrorView({ message }) {
4360
4462
  /* @__PURE__ */ jsx23(Text21, { color: theme2.muted, children: "Fix the file (or restore config.json.bak) and press r to reload." })
4361
4463
  ] });
4362
4464
  }
4363
- var GLOBAL_HINTS;
4364
4465
  var init_App = __esm({
4365
4466
  "src/ui/App.tsx"() {
4366
4467
  "use strict";
@@ -4374,11 +4475,6 @@ var init_App = __esm({
4374
4475
  init_router();
4375
4476
  init_screens();
4376
4477
  init_theme();
4377
- GLOBAL_HINTS = [
4378
- { key: "tab", label: "pane" },
4379
- { key: "?", label: "help" },
4380
- { key: "q", label: "quit" }
4381
- ];
4382
4478
  }
4383
4479
  });
4384
4480
 
@@ -6362,11 +6458,24 @@ import path18 from "path";
6362
6458
 
6363
6459
  // src/core/service/systemctl.ts
6364
6460
  import { execFile } from "child_process";
6461
+ import { existsSync } from "fs";
6462
+ function userBusEnv(env = process.env, uid = process.getuid?.() ?? null, exists = existsSync) {
6463
+ const out = { ...env };
6464
+ if (!out.XDG_RUNTIME_DIR && uid !== null) {
6465
+ const dir = `/run/user/${uid}`;
6466
+ if (exists(dir)) out.XDG_RUNTIME_DIR = dir;
6467
+ }
6468
+ if (!out.DBUS_SESSION_BUS_ADDRESS && out.XDG_RUNTIME_DIR) {
6469
+ const bus = `${out.XDG_RUNTIME_DIR}/bus`;
6470
+ if (exists(bus)) out.DBUS_SESSION_BUS_ADDRESS = `unix:path=${bus}`;
6471
+ }
6472
+ return out;
6473
+ }
6365
6474
  var execRunner = (command, args) => new Promise((resolve) => {
6366
6475
  execFile(
6367
6476
  command,
6368
6477
  args,
6369
- { timeout: 3e4, maxBuffer: 4 * 1024 * 1024 },
6478
+ { timeout: 3e4, maxBuffer: 4 * 1024 * 1024, env: userBusEnv() },
6370
6479
  (error, stdout, stderr) => {
6371
6480
  const err = error;
6372
6481
  if (err && err.code === "ENOENT") {
@@ -6452,6 +6561,10 @@ function renderUnit({ command, configPath }) {
6452
6561
  }
6453
6562
 
6454
6563
  // src/core/service/manager.ts
6564
+ function busHint(message, user) {
6565
+ if (!/Failed to connect to bus|XDG_RUNTIME_DIR not defined/i.test(message)) return message;
6566
+ return `${message} \u2014 no systemd user session for ${user}: log in as ${user} directly (e.g. \`ssh ${user}@host\`, not \`su\`/\`sudo -u\`), or have an administrator run \`loginctl enable-linger ${user}\` once, then retry`;
6567
+ }
6455
6568
  var ServiceError = class extends Error {
6456
6569
  name = "ServiceError";
6457
6570
  };
@@ -6503,7 +6616,10 @@ var ServiceManager = class {
6503
6616
  if (show.code !== 0) {
6504
6617
  return {
6505
6618
  ...base,
6506
- error: (show.stderr || show.stdout).trim() || `systemctl exited with ${show.code}`
6619
+ error: busHint(
6620
+ (show.stderr || show.stdout).trim() || `systemctl exited with ${show.code}`,
6621
+ this.user
6622
+ )
6507
6623
  };
6508
6624
  }
6509
6625
  const p = parseProperties(show.stdout);
@@ -6709,8 +6825,8 @@ import { Box, renderToString, Text } from "ink";
6709
6825
  import Gradient from "ink-gradient";
6710
6826
  import { jsx, jsxs } from "react/jsx-runtime";
6711
6827
  function printOptions(stream = process.stdout) {
6712
- const plain = !stream.isTTY || "NO_COLOR" in process.env && process.env.NO_COLOR !== "0";
6713
- return { plain, columns: stream.columns || 100 };
6828
+ const plain2 = !stream.isTTY || "NO_COLOR" in process.env && process.env.NO_COLOR !== "0";
6829
+ return { plain: plain2, columns: stream.columns || 100 };
6714
6830
  }
6715
6831
  function render(element, options) {
6716
6832
  return renderToString(element, { columns: options.columns ?? 100 });