vde-layout 1.0.0 → 1.0.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/README.md CHANGED
@@ -30,7 +30,7 @@ pnpm run ci
30
30
  ```
31
31
 
32
32
  ## Quick Start
33
- 1. Create a YAML file at `~/.config/vde/layout.yml` (or any supported location; see "Configuration Search Order").
33
+ 1. Create a YAML file at `~/.config/vde/layout/config.yml` (legacy `~/.config/vde/layout.yml` is also supported; see "Configuration Search Order").
34
34
  2. Paste a preset definition:
35
35
  ```yaml
36
36
  presets:
@@ -73,7 +73,7 @@ pnpm run ci
73
73
  - `vde-layout dev --backend wezterm` - Use the WezTerm backend (defaults to `tmux` when omitted).
74
74
  - `vde-layout dev --current-window` - Reuse the current tmux window (or active WezTerm tab) after confirming that other panes can be closed.
75
75
  - `vde-layout dev --new-window` - Force creation of a new tmux window or WezTerm tab even when presets or defaults request reuse.
76
- - `vde-layout --config /path/to/layout.yml` - Load presets from a specific file.
76
+ - `vde-layout --config /path/to/config.yml` - Load presets from a specific file.
77
77
  - `vde-layout --help` - Show usage.
78
78
  - `vde-layout --version` / `vde-layout -v` - Print package version.
79
79
 
@@ -88,12 +88,16 @@ vde-layout resolves backends in the following order: CLI flag (`--backend`), pre
88
88
  - `--new-window` spawns a new tab in the active window when one is available, otherwise creates a fresh window.
89
89
 
90
90
  ## Configuration Search Order
91
- When no `--config` flag is provided, vde-layout searches for configuration files in the following order:
92
- 1. `$VDE_CONFIG_PATH/layout.yml` (if `VDE_CONFIG_PATH` is set).
93
- 2. `$XDG_CONFIG_HOME/vde/layout.yml` or `~/.config/vde/layout.yml` when `XDG_CONFIG_HOME` is unset.
94
- 3. `<project-root>/.vde/layout.yml` (discovered by walking up from the current directory).
95
-
96
- All existing files are merged, with project-specific definitions taking precedence over shared ones.
91
+ When no `--config` flag is provided, vde-layout checks candidate files in this order for `findConfigFile()`:
92
+ 1. `<project-root>/.vde/layout.yml` (discovered by walking up from the current directory).
93
+ 2. `$VDE_CONFIG_PATH/layout.yml` (if `VDE_CONFIG_PATH` is set).
94
+ 3. `$XDG_CONFIG_HOME/vde/layout/config.yml` (or `~/.config/vde/layout/config.yml` when `XDG_CONFIG_HOME` is unset).
95
+ 4. `$XDG_CONFIG_HOME/vde/layout.yml` fallback (or `~/.config/vde/layout.yml`).
96
+
97
+ For `loadConfig()`, vde-layout merges shared scopes first and project scope last:
98
+ 1. `$VDE_CONFIG_PATH/layout.yml`
99
+ 2. XDG scope (`.../vde/layout/config.yml` or fallback `.../vde/layout.yml`; first existing file only)
100
+ 3. `<project-root>/.vde/layout.yml`
97
101
 
98
102
  ## Preset Structure
99
103
  Each preset is an object under the `presets` key:
package/dist/index.mjs CHANGED
@@ -248,7 +248,8 @@ const createConfigLoader = (options = {}) => {
248
248
  const candidates = [];
249
249
  const projectCandidate = findProjectConfigCandidate();
250
250
  if (projectCandidate !== null) candidates.push(projectCandidate);
251
- candidates.push(...buildDefaultSearchPaths());
251
+ const defaultSearchPathGroups = buildDefaultSearchPathGroups();
252
+ candidates.push(...flattenSearchPathGroups(defaultSearchPathGroups));
252
253
  return [...new Set(candidates)];
253
254
  };
254
255
  const loadConfig = async () => {
@@ -258,16 +259,16 @@ const createConfigLoader = (options = {}) => {
258
259
  return validateYAML(await safeReadFile(filePath));
259
260
  }
260
261
  const searchPaths = computeCachedSearchPaths();
261
- const existingPaths = await filterExistingPaths(searchPaths);
262
- if (existingPaths.length === 0) throw createConfigError("Configuration file not found", ErrorCodes.CONFIG_NOT_FOUND, { searchPaths });
262
+ const globalPaths = await resolveFirstExistingPaths(buildDefaultSearchPathGroups());
263
263
  const projectPath = findProjectConfigCandidate();
264
- const globalPaths = existingPaths.filter((filePath) => filePath !== projectPath);
264
+ const projectConfigExists = projectPath !== null ? await fs.pathExists(projectPath) : false;
265
+ if (globalPaths.length === 0 && !projectConfigExists) throw createConfigError("Configuration file not found", ErrorCodes.CONFIG_NOT_FOUND, { searchPaths });
265
266
  let mergedConfig = { presets: {} };
266
267
  for (const globalPath of globalPaths) {
267
268
  const config = validateYAML(await safeReadFile(globalPath));
268
269
  mergedConfig = mergeConfigs(mergedConfig, config, emitWarning);
269
270
  }
270
- if (projectPath !== null && await fs.pathExists(projectPath)) {
271
+ if (projectPath !== null && projectConfigExists) {
271
272
  const config = validateYAML(await safeReadFile(projectPath));
272
273
  mergedConfig = mergeConfigs(mergedConfig, config, emitWarning);
273
274
  }
@@ -287,15 +288,30 @@ const createConfigLoader = (options = {}) => {
287
288
  getSearchPaths: () => computeCachedSearchPaths()
288
289
  };
289
290
  };
290
- const buildDefaultSearchPaths = () => {
291
- const paths = [];
291
+ const buildDefaultSearchPathGroups = () => {
292
+ const pathGroups = [];
292
293
  const vdeConfigPath = process.env.VDE_CONFIG_PATH;
293
- if (vdeConfigPath !== void 0) paths.push(path.join(vdeConfigPath, "layout.yml"));
294
+ if (vdeConfigPath !== void 0) pathGroups.push([path.join(vdeConfigPath, "layout.yml")]);
294
295
  const homeDir = process.env.HOME ?? os.homedir();
295
296
  const xdgConfigHome = process.env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config");
296
- paths.push(path.join(xdgConfigHome, "vde", "layout.yml"));
297
+ pathGroups.push([path.join(xdgConfigHome, "vde", "layout", "config.yml"), path.join(xdgConfigHome, "vde", "layout.yml")]);
298
+ return pathGroups.map((group) => [...new Set(group)]);
299
+ };
300
+ const flattenSearchPathGroups = (pathGroups) => {
301
+ const paths = [];
302
+ for (const group of pathGroups) paths.push(...group);
297
303
  return [...new Set(paths)];
298
304
  };
305
+ const resolveFirstExistingPaths = async (pathGroups) => {
306
+ const existingPaths = await Promise.all(pathGroups.map(async (group) => findFirstExisting(group)));
307
+ const seenPaths = /* @__PURE__ */ new Set();
308
+ const resolvedPaths = [];
309
+ for (const existingPath of existingPaths) if (existingPath !== null && !seenPaths.has(existingPath)) {
310
+ seenPaths.add(existingPath);
311
+ resolvedPaths.push(existingPath);
312
+ }
313
+ return resolvedPaths;
314
+ };
299
315
  const findProjectConfigCandidate = () => {
300
316
  let currentDir = process.cwd();
301
317
  const { root } = path.parse(currentDir);
@@ -313,11 +329,6 @@ const findFirstExisting = async (paths) => {
313
329
  for (const candidate of paths) if (await fs.pathExists(candidate)) return candidate;
314
330
  return null;
315
331
  };
316
- const filterExistingPaths = async (paths) => {
317
- const existing = [];
318
- for (const candidate of paths) if (await fs.pathExists(candidate)) existing.push(candidate);
319
- return existing;
320
- };
321
332
  const safeReadFile = async (filePath) => {
322
333
  try {
323
334
  return await fs.readFile(filePath, "utf8");
@@ -630,14 +641,14 @@ const sanitizeLayoutForSchemaValidation = (node) => {
630
641
  };
631
642
  };
632
643
  const convertLayoutIssueToCompileError = ({ issue, source, basePath, layout }) => {
633
- if (issue.path[0] === "panes" && issue.code === "invalid_type") return compileError("LAYOUT_PANES_MISSING", {
644
+ if (isMissingArrayIssue(issue, "panes")) return compileError("LAYOUT_PANES_MISSING", {
634
645
  source,
635
- message: "panes array is missing",
646
+ message: "panes array is missing or empty",
636
647
  path: `${basePath}.panes`
637
648
  });
638
- if (issue.path[0] === "ratio" && issue.code === "invalid_type") return compileError("LAYOUT_RATIO_MISSING", {
649
+ if (isMissingArrayIssue(issue, "ratio")) return compileError("LAYOUT_RATIO_MISSING", {
639
650
  source,
640
- message: "ratio array is missing",
651
+ message: "ratio array is missing or empty",
641
652
  path: `${basePath}.ratio`
642
653
  });
643
654
  if (issue.path.includes("type")) return compileError("LAYOUT_INVALID_ORIENTATION", {
@@ -668,6 +679,10 @@ const convertLayoutIssueToCompileError = ({ issue, source, basePath, layout }) =
668
679
  }
669
680
  });
670
681
  };
682
+ const isMissingArrayIssue = (issue, field) => {
683
+ if (issue.path.length !== 1 || issue.path[0] !== field) return false;
684
+ return issue.code === "invalid_type" || issue.code === "too_small" && issue.type === "array";
685
+ };
671
686
  const getRatioLengthDetails = (layout) => {
672
687
  if (!isRecord(layout)) return;
673
688
  const ratio = layout.ratio;
@@ -1264,34 +1279,33 @@ const resolvePaneMapping = (paneMap, virtualId) => {
1264
1279
 
1265
1280
  //#endregion
1266
1281
  //#region src/executor/split-step.ts
1267
- const resolveSplitOrientation = (step) => {
1282
+ const asSplitStep = (step, field) => {
1268
1283
  if (step.kind !== "split") throw createCoreError("execution", {
1269
1284
  code: ErrorCodes.INVALID_PLAN,
1270
- message: "Non-split step cannot resolve split orientation",
1285
+ message: `Non-split step cannot resolve split ${field}`,
1271
1286
  path: step.id,
1272
1287
  details: { kind: step.kind }
1273
1288
  });
1274
- if (step.orientation === "horizontal" || step.orientation === "vertical") return step.orientation;
1289
+ return step;
1290
+ };
1291
+ const resolveSplitOrientation = (step) => {
1292
+ const splitStep = asSplitStep(step, "orientation");
1293
+ if (splitStep.orientation === "horizontal" || splitStep.orientation === "vertical") return splitStep.orientation;
1275
1294
  throw createCoreError("execution", {
1276
1295
  code: ErrorCodes.INVALID_PLAN,
1277
1296
  message: "Split step missing orientation metadata",
1278
- path: step.id,
1279
- details: { orientation: step.orientation }
1297
+ path: splitStep.id,
1298
+ details: { orientation: splitStep.orientation }
1280
1299
  });
1281
1300
  };
1282
1301
  const resolveSplitPercentage = (step) => {
1283
- if (step.kind !== "split") throw createCoreError("execution", {
1284
- code: ErrorCodes.INVALID_PLAN,
1285
- message: "Non-split step cannot resolve split percentage",
1286
- path: step.id,
1287
- details: { kind: step.kind }
1288
- });
1289
- if (typeof step.percentage === "number" && Number.isFinite(step.percentage)) return String(clampSplitPercentage(step.percentage));
1302
+ const splitStep = asSplitStep(step, "percentage");
1303
+ if (typeof splitStep.percentage === "number" && Number.isFinite(splitStep.percentage)) return String(clampSplitPercentage(splitStep.percentage));
1290
1304
  throw createCoreError("execution", {
1291
1305
  code: ErrorCodes.INVALID_PLAN,
1292
1306
  message: "Split step missing percentage metadata",
1293
- path: step.id,
1294
- details: { percentage: step.percentage }
1307
+ path: splitStep.id,
1308
+ details: { percentage: splitStep.percentage }
1295
1309
  });
1296
1310
  };
1297
1311
  const clampSplitPercentage = (value) => {