<script lang="ts">
// Every option `wsb refactor` takes, on the directory that was right-clicked.
//
// The dialog only PREVIEWS. It computes what would happen and opens the plan
// in a tab to be read; nothing is written until the reader says so there.
// What they tick is remembered, because a refactor is rarely done once and
// re-choosing the same six flags every time is how the wrong six get chosen.
import { ui } from "../../stores/ui.svelte.js";
import { workbench } from "../../stores/workbench.svelte.js";
import { refactors } from "../../stores/refactor-tabs.svelte.js";
import {
rememberedOptions,
rememberOptions,
usablePairs,
type RefactorOptions,
} from "../../stores/refactor.svelte.js";
import type { PatternPair } from "../../api/client.js";
import AppIcon from "../layout/AppIcon.svelte";
// Always one blank row at the end, so adding a replacement is typing
// rather than finding a button first.
let pairs = $state<PatternPair[]>([{ pattern: "", substitute: "" }]);
let options = $state<RefactorOptions>(rememberedOptions());
let excludeText = $state(options.exclude.join(", "));
let includeText = $state(options.include.join(", "));
let busy = $state(false);
let error = $state<string | null>(null);
const target = $derived(ui.refactorTarget ?? "");
const usable = $derived(usablePairs(pairs));
/** Keep exactly one empty row at the end, and never fewer than one row. */
function tidyRows() {
while (
pairs.length > 1 &&
pairs[pairs.length - 1]!.pattern === "" &&
pairs[pairs.length - 2]!.pattern === ""
) {
pairs.pop();
}
if (pairs.length === 0 || pairs[pairs.length - 1]!.pattern !== "") {
pairs.push({ pattern: "", substitute: "" });
}
}
function removeRow(index: number) {
pairs.splice(index, 1);
tidyRows();
}
/** The four mode flags are mutually exclusive, exactly as the CLI has them. */
const modes = [
{ key: "files_only", label: "Files only", hint: "skip directories" },
{ key: "dirs_only", label: "Directories only", hint: "skip files" },
{ key: "names_only", label: "Names only", hint: "rename, never edit contents" },
{ key: "content_only", label: "Contents only", hint: "edit, never rename" },
] as const;
const flags = [
{ key: "gitignore", label: "Skip what git ignores", hint: "git decides, not a pattern here" },
{ key: "ignore_case", label: "Ignore case", hint: "match regardless of case" },
{ key: "use_regex", label: "Regex", hint: "the pattern is a regular expression" },
{ key: "include_hidden", label: "Include hidden", hint: "files starting with a dot" },
{ key: "binary_names", label: "Rename binaries", hint: "their contents are never edited" },
{ key: "follow_symlinks", label: "Follow symlinks", hint: "descend through links" },
{ key: "backup", label: "Back up before editing", hint: "keep the original beside it" },
] as const;
function chooseMode(key: (typeof modes)[number]["key"]) {
const wasOn = options[key];
for (const mode of modes) options[mode.key] = false;
// Clicking the active mode turns it off, which is the CLI's default: all
// files and directories, names and contents.
options[key] = !wasOn;
}
function close() {
ui.activeModal = null;
ui.refactorTarget = null;
}
function splitPatterns(text: string): string[] {
return text
.split(",")
.map((piece) => piece.trim())
.filter((piece) => piece.length > 0);
}
async function preview() {
if (usable.length === 0 || busy) return;
busy = true;
error = null;
const chosen: RefactorOptions = {
...options,
exclude: splitPatterns(excludeText),
include: splitPatterns(includeText),
};
rememberOptions(chosen);
try {
const refactor = await refactors.start({
root: target,
pairs: usable,
...chosen,
});
if (refactor.error) {
error = refactor.error;
return;
}
if (refactor.plan) {
workbench.openRefactor(refactor.plan.id, target);
close();
}
} catch (thrown) {
error = thrown instanceof Error ? thrown.message : String(thrown);
} finally {
busy = false;
}
}
function onOverlay(event: MouseEvent) {
if (!busy && (event.target as HTMLElement).classList.contains("modal-overlay")) close();
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape" && !busy) close();
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) void preview();
}
</script>
<svelte:window onkeydown={onKey} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={onOverlay}>
<div class="modal refactor-modal" role="dialog" aria-modal="true" aria-label="Refactor">
<header class="modal-head">
<h2>Refactor</h2>
<code class="target" title={target}>{target || "(no directory)"}</code>
</header>
<div class="modal-body">
<fieldset class="group">
<legend>Replacements</legend>
{#each pairs as pair, index (index)}
<div class="pair">
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={pair.pattern}
autofocus={index === 0}
oninput={tidyRows}
placeholder="text to find"
spellcheck="false"
aria-label="find"
/>
<span class="pair-arrow"><AppIcon name="arrow-right" size={11} /></span>
<input
bind:value={pair.substitute}
placeholder="replacement"
spellcheck="false"
aria-label="replace with"
/>
<button
type="button"
class="drop"
title="remove this replacement"
aria-label="remove replacement {index + 1}"
disabled={pairs.length === 1}
onclick={() => removeRow(index)}
><AppIcon name="x" size={11} /></button>
</div>
{/each}
<p class="hint">
Applied in order, each to what the one before it produced — the same
rule a patterns file follows.
</p>
</fieldset>
<fieldset class="group">
<legend>What to change</legend>
<div class="chips">
{#each modes as mode (mode.key)}
<button
type="button"
class="chip"
class:on={options[mode.key]}
title={mode.hint}
onclick={() => chooseMode(mode.key)}
>
{mode.label}
</button>
{/each}
</div>
<p class="hint">Nothing chosen means everything: files and directories, names and contents.</p>
</fieldset>
<fieldset class="group">
<legend>How</legend>
{#each flags as flag (flag.key)}
<label class="check" title={flag.hint}>
<input type="checkbox" bind:checked={options[flag.key]} />
<span>{flag.label}</span>
<span class="hint-inline">{flag.hint}</span>
</label>
{/each}
</fieldset>
<fieldset class="group">
<legend>Narrowing</legend>
<!-- A plain wrapper inside the fieldset, because a `fieldset` is not a
reliable flex or grid container: its anonymous content box breaks
the layout in ways that differ between browsers. The grid lives on
a div, where it behaves. -->
<div class="narrow">
<label class="narrow-label" for="refactor-include">Include</label>
<input
id="refactor-include"
class="narrow-input"
bind:value={includeText}
placeholder="*.rs, *.toml — comma separated"
spellcheck="false"
/>
<label class="narrow-label" for="refactor-exclude">Exclude</label>
<input
id="refactor-exclude"
class="narrow-input"
bind:value={excludeText}
placeholder="*.lock — comma separated"
spellcheck="false"
/>
<label class="narrow-label" for="refactor-depth">Max depth</label>
<div class="narrow-depth">
<input
id="refactor-depth"
class="narrow-number"
type="number"
min="0"
bind:value={options.max_depth}
/>
<span class="hint-inline">0 descends all the way down</span>
</div>
</div>
</fieldset>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
</div>
<footer class="modal-foot">
<span class="foot-note">Previewing writes nothing.</span>
<button class="btn" onclick={close} disabled={busy}>Cancel</button>
<button class="btn primary" onclick={preview} disabled={busy || usable.length === 0 || !target}>
{busy ? "Previewing…" : "Preview changes"}
</button>
</footer>
</div>
</div>
<style>
.refactor-modal {
width: min(620px, 92vw);
max-height: 86vh;
display: flex;
flex-direction: column;
background: var(--vscode-editor-background);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wb-corner-radius-6);
}
.modal-head {
padding: var(--wb-spacing-12) var(--wb-spacing-16) var(--wb-spacing-8);
border-bottom: 1px solid var(--vscode-panel-border);
}
.modal-head h2 {
margin: 0;
font-size: 1.05em;
}
.target {
display: block;
margin-top: var(--wb-spacing-2);
opacity: 0.7;
font-size: 0.85em;
overflow-wrap: anywhere;
}
.modal-body {
padding: var(--wb-spacing-12) var(--wb-spacing-16);
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--wb-spacing-12);
}
/* Label left, control right, on one line. These were stacked and centred,
which left most of a 620px dialog empty and put a number box the width of
the whole modal under a one-word label. */
/* Two columns: the labels in one, the controls in the other, filling the
width. Every label ends at the same place, so the controls start at the
same place — which is the whole point of a column and what a stack of
centred rows cannot do. */
.narrow {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: var(--wb-spacing-10);
row-gap: var(--wb-spacing-6);
align-items: center;
}
.narrow-label {
opacity: 0.8;
/* Clicking the label focuses its control, which is the reason each one
carries a `for`. */
cursor: pointer;
}
.narrow-input {
min-width: 0;
padding: var(--wb-spacing-4) var(--wb-spacing-6);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wb-corner-radius-3);
}
.narrow-depth {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
}
.narrow-number {
/* Wide enough for a depth nobody will exceed, and no wider: a number box
stretched across the dialog reads as somewhere to type a sentence. */
flex: 0 0 76px;
padding: var(--wb-spacing-4) var(--wb-spacing-6);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wb-corner-radius-3);
}
/* Block, not flex. A `fieldset` is not a dependable flex container — its
anonymous content box makes the layout differ between browsers, which is
how one group came out as centred rows with gaps between them while
another with the same rules looked right. The rows space themselves. */
.group {
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wb-corner-radius-4);
padding: var(--wb-spacing-8) var(--wb-spacing-10);
}
.group > * + * {
margin-top: var(--wb-spacing-6);
}
legend {
padding: 0 var(--wb-spacing-4);
opacity: 0.7;
font-size: 0.85em;
}
.pair {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
}
.pair input {
flex: 1;
min-width: 0;
padding: var(--wb-spacing-4) var(--wb-spacing-6);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wb-corner-radius-3);
}
.pair-arrow {
opacity: 0.5;
display: inline-flex;
align-items: center;
gap: 2px;
}
.drop {
flex: 0 0 auto;
width: var(--wb-control-size-22);
border: none;
background: none;
color: inherit;
font: inherit;
opacity: 0.5;
cursor: pointer;
}
.drop:hover:not(:disabled) {
opacity: 1;
color: var(--vscode-testing-iconFailed, #f44336);
}
.drop:disabled {
opacity: 0.15;
cursor: default;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--wb-spacing-6);
}
.chip {
padding: var(--wb-spacing-3) var(--wb-spacing-9);
border: 1px solid var(--vscode-panel-border);
border-radius: 999px;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.chip.on {
background: var(--vscode-button-background, #0e639c);
color: var(--vscode-button-foreground, #fff);
border-color: transparent;
}
.check {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
}
.hint,
.hint-inline {
opacity: 0.55;
font-size: 0.85em;
}
.hint {
margin: 0;
}
.error {
margin: 0;
padding: var(--wb-spacing-6) var(--wb-spacing-8);
border-left: 2px solid var(--vscode-testing-iconFailed, #f44336);
background: var(--vscode-inputValidation-errorBackground, rgba(244, 67, 54, 0.12));
}
.modal-foot {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-10) var(--wb-spacing-16);
border-top: 1px solid var(--vscode-panel-border);
}
.foot-note {
flex: 1;
opacity: 0.6;
font-size: 0.85em;
}
.btn {
padding: var(--wb-spacing-4) var(--wb-spacing-12);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wb-corner-radius-3);
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.btn.primary {
background: var(--vscode-button-background, #0e639c);
color: var(--vscode-button-foreground, #fff);
border-color: transparent;
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
</style>