Release Git Tools 0.1.0

This commit is contained in:
2026-09-01 13:05:57 +02:00
commit ed0acbf8c8
57 changed files with 9605 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => ({
default: (await import("./components/Workbench")).Workbench,
}));
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<ErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
>
<Suspense
fallback={
<p className="loading" role="status">
Preparing Git Tools
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</ErrorBoundary>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
export class ErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Application failure", error, info);
}
render() {
if (this.state.error)
return (
<main className="fatal">
<h1>Git Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => location.reload()}>
Reload
</button>
</main>
);
return this.props.children;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useRef } from "react";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const node = dialog.current;
if (!node) return;
if (open && !node.open) node.showModal();
if (!open && node.open) node.close();
}, [open]);
return (
<dialog
ref={dialog}
className="help-dialog"
onClose={onClose}
onCancel={onClose}
aria-labelledby="help-title"
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local-first help</p>
<h2 id="help-title">About Git Tools</h2>
</div>
<button type="button" onClick={onClose} aria-label="Close help">
×
</button>
</div>
<p>Inspect and author Git interchange formats locally in the browser.</p>
<p>
All processing is performed in this browser. Imported data is treated as
untrusted and bounded before parsing.
</p>
</dialog>
);
}
+760
View File
@@ -0,0 +1,760 @@
import { useMemo, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
CONVENTIONAL_TYPES,
buildConventionalCommit,
compareSemVer,
createUnifiedPatch,
explainGitignore,
lintConventionalCommit,
normalizeChangelog,
parseSemVer,
parseUnifiedPatch,
satisfiesSemVer,
type IgnoreResult,
type PatchDocument,
} from "../git/tools";
const patchSample = `diff --git a/src/example.ts b/src/example.ts
index 18d541c..d7042aa 100644
--- a/src/example.ts
+++ b/src/example.ts
@@ -1,4 +1,5 @@
export function greet(name: string) {
- return "Hello " + name;
+ const value = name.trim();
+ return "Hello " + value;
}
`;
const beforeSample = `export function greet(name: string) {
return "Hello " + name;
}
`;
const afterSample = `export function greet(name: string) {
const value = name.trim();
return "Hello " + value;
}
`;
const ignoreSample = `# Build output
dist/
*.log
!.keep.log
cache/
!cache/README.md
`;
const changelogSample = `# Changelog
## 1.2.0 - 2026-08-30
### fixed
- Handle empty input
- Handle empty input
### Added
- Add patch inspector
## Unreleased
### changed
- Improve diagnostics
`;
type Tab = "patch" | "author" | "ignore" | "versions" | "commit" | "changelog";
function download(value: string, filename: string, type = "text/plain") {
triggerBlobDownload(
new Blob([value], { type: `${type};charset=utf-8` }),
filename,
);
}
export function Workbench() {
const [tab, setTab] = useState<Tab>("patch");
const [error, setError] = useState("");
const [patchSource, setPatchSource] = useState(patchSample);
const [patch, setPatch] = useState<PatchDocument>(() =>
parseUnifiedPatch(patchSample),
);
const [before, setBefore] = useState(beforeSample);
const [after, setAfter] = useState(afterSample);
const [path, setPath] = useState("src/example.ts");
const [context, setContext] = useState(3);
const [authored, setAuthored] = useState(() =>
createUnifiedPatch(beforeSample, afterSample, "src/example.ts", 3),
);
const [ignoreSource, setIgnoreSource] = useState(ignoreSample);
const [ignorePaths, setIgnorePaths] = useState(
"dist/app.js\nnotes.log\n.keep.log\ncache/README.md\nsrc/main.ts",
);
const [ignoreResults, setIgnoreResults] = useState<IgnoreResult[]>(() =>
explainGitignore(ignoreSample, ignorePaths.split("\n")),
);
const [versions, setVersions] = useState(
"1.2.3\n1.3.0-beta.1\n1.3.0\n2.0.0\n0.9.8",
);
const [range, setRange] = useState("^1.2.0");
const [message, setMessage] = useState(
"feat(git): add local patch inspection",
);
const [type, setType] = useState("feat");
const [scope, setScope] = useState("git");
const [breaking, setBreaking] = useState(false);
const [subject, setSubject] = useState("add local patch inspection");
const [body, setBody] = useState("");
const [breakingDescription, setBreakingDescription] = useState("");
const [references, setReferences] = useState("");
const [changelog, setChangelog] = useState(changelogSample);
const [normalized, setNormalized] = useState(() =>
normalizeChangelog(changelogSample),
);
const linted = useMemo(() => lintConventionalCommit(message), [message]);
const versionRows = useMemo(
() =>
versions
.split(/\r?\n/u)
.filter((value) => value.trim())
.slice(0, 1_000)
.map((value) => {
try {
const parsed = parseSemVer(value);
return {
value,
parsed,
matches: satisfiesSemVer(value, range),
error: "",
};
} catch (reason) {
return {
value,
parsed: null,
matches: false,
error:
reason instanceof Error ? reason.message : "Invalid version.",
};
}
})
.sort((left, right) =>
left.parsed && right.parsed
? compareSemVer(right.parsed, left.parsed)
: left.parsed
? -1
: 1,
),
[versions, range],
);
const inspectPatch = () => {
try {
setPatch(parseUnifiedPatch(patchSource));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Patch inspection failed.",
);
}
};
const authorPatch = () => {
try {
setAuthored(createUnifiedPatch(before, after, path, context));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Patch authoring failed.",
);
}
};
const testIgnore = () => {
try {
setIgnoreResults(
explainGitignore(ignoreSource, ignorePaths.split(/\r?\n/u)),
);
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : ".gitignore test failed.",
);
}
};
const buildCommit = () => {
try {
setMessage(
buildConventionalCommit({
type,
scope,
breaking,
subject,
body,
breakingDescription,
issues: references,
}),
);
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Commit builder failed.",
);
}
};
const runChangelog = () => {
try {
setNormalized(normalizeChangelog(changelog));
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Changelog normalization failed.",
);
}
};
return (
<main className="workbench">
<header className="hero">
<div>
<p className="eyebrow">Repository interchange workbench</p>
<h1>Git Tools</h1>
<p>
Inspect and author patches, explain ignore rules, reason about
versions, and normalize release text entirely in this browser.
</p>
</div>
<span className="privacy-pill">Browser-local</span>
</header>
{error && (
<p className="alert" role="alert">
{error}
</p>
)}
<nav className="workspace-tabs" aria-label="Git workspaces">
{(
[
"patch",
"author",
"ignore",
"versions",
"commit",
"changelog",
] as const
).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
>
{value === "ignore"
? ".gitignore"
: value[0]!.toUpperCase() + value.slice(1)}
</button>
))}
</nav>
{tab === "patch" && (
<div className="split-layout">
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Unified / Git patch</p>
<h2>Patch source</h2>
</div>
</div>
<textarea
aria-label="Patch source"
value={patchSource}
onChange={(event) => setPatchSource(event.target.value)}
spellCheck={false}
/>
<div className="actions">
<button type="button" className="primary" onClick={inspectPatch}>
Inspect patch
</button>
<button
type="button"
onClick={() =>
download(patchSource, "changes.patch", "text/x-diff")
}
>
Download .patch
</button>
</div>
<p className="muted">
The last valid inspection remains visible when later input is
malformed. Applying patches is intentionally out of scope.
</p>
</section>
<section className="panel workspace">
<div className="facts">
<div>
<span>Files</span>
<strong>{patch.files.length}</strong>
</div>
<div>
<span>Added lines</span>
<strong className="added">+{patch.added}</strong>
</div>
<div>
<span>Deleted lines</span>
<strong className="deleted">{patch.deleted}</strong>
</div>
<div>
<span>Diagnostics</span>
<strong>{patch.diagnostics.length}</strong>
</div>
</div>
<div className="file-list">
{patch.files.map((file, fileIndex) => (
<article key={`${file.oldPath}-${file.newPath}-${fileIndex}`}>
<header>
<div>
<strong>{file.newPath}</strong>
<span>
{file.status}
{file.oldPath !== file.newPath
? ` · from ${file.oldPath}`
: ""}
</span>
</div>
<span>
<b className="added">+{file.added}</b>{" "}
<b className="deleted">{file.deleted}</b>
</span>
</header>
{file.binary ? (
<p>Binary content marker; no line payload interpreted.</p>
) : (
file.hunks.map((hunk) => (
<details key={hunk.header}>
<summary>
{hunk.header} ·{" "}
{hunk.validCounts ? "counts valid" : "count mismatch"}
</summary>
<div
className="patch-lines"
role="table"
aria-label={hunk.header}
>
{hunk.lines.slice(0, 2_000).map((line, index) => (
<div
key={`${line.oldLine}-${line.newLine}-${index}`}
data-kind={line.kind}
role="row"
>
<span>{line.oldLine ?? ""}</span>
<span>{line.newLine ?? ""}</span>
<code>
{line.kind === "add"
? "+"
: line.kind === "delete"
? "-"
: line.kind === "context"
? " "
: ""}
{line.text}
</code>
</div>
))}
</div>
</details>
))
)}
</article>
))}
</div>
{patch.diagnostics.length > 0 && (
<ul className="diagnostics">
{patch.diagnostics.map((item, index) => (
<li key={`${item}-${index}`}>{item}</li>
))}
</ul>
)}
</section>
</div>
)}
{tab === "author" && (
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Before after</p>
<h2>Unified patch authoring</h2>
</div>
<button type="button" className="primary" onClick={authorPatch}>
Generate patch
</button>
</div>
<div className="author-controls">
<label className="field">
<span>Repository-relative path</span>
<input
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
<label className="field">
<span>Context lines</span>
<input
type="number"
min="0"
max="20"
value={context}
onChange={(event) => setContext(Number(event.target.value))}
/>
</label>
</div>
<div className="triple-editors">
<label className="field">
<span>Before</span>
<textarea
aria-label="Before text"
value={before}
onChange={(event) => setBefore(event.target.value)}
/>
</label>
<label className="field">
<span>After</span>
<textarea
aria-label="After text"
value={after}
onChange={(event) => setAfter(event.target.value)}
/>
</label>
<label className="field">
<span>Generated patch</span>
<textarea
aria-label="Generated patch"
value={authored}
readOnly
/>
</label>
</div>
<div className="actions">
<button
type="button"
disabled={!authored}
onClick={() =>
download(authored, "authored.patch", "text/x-diff")
}
>
Download generated patch
</button>
<button
type="button"
disabled={!authored}
onClick={() => {
setPatchSource(authored);
if (authored) setPatch(parseUnifiedPatch(authored));
setTab("patch");
}}
>
Inspect generated patch
</button>
</div>
</section>
)}
{tab === "ignore" && (
<div className="split-layout">
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Root .gitignore</p>
<h2>Rules and candidates</h2>
</div>
<button type="button" className="primary" onClick={testIgnore}>
Explain matches
</button>
</div>
<label className="field">
<span>.gitignore contents</span>
<textarea
aria-label=".gitignore source"
value={ignoreSource}
onChange={(event) => setIgnoreSource(event.target.value)}
/>
</label>
<label className="field">
<span>Paths, one per line; end directories with /</span>
<textarea
aria-label="Paths to test"
value={ignorePaths}
onChange={(event) => setIgnorePaths(event.target.value)}
/>
</label>
</section>
<section className="panel workspace">
<div className="result-list">
{ignoreResults.map((result, index) => (
<article
key={`${result.path}-${index}`}
data-ignored={result.ignored}
>
<header>
<code>{result.path}</code>
<strong>{result.ignored ? "ignored" : "included"}</strong>
</header>
<p>{result.explanation}</p>
{result.matched.length > 0 && (
<ol>
{result.matched.map((match) => (
<li key={match.line}>
Line {match.line}: <code>{match.source}</code> {" "}
{match.outcome}
</li>
))}
</ol>
)}
</article>
))}
</div>
<p className="muted">
This evaluates one repository-root ignore file. Nested .gitignore
files, global excludes, index state, and submodule boundaries are
not loaded.
</p>
</section>
</div>
)}
{tab === "versions" && (
<div className="split-layout">
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">SemVer 2.0 precedence</p>
<h2>Compare and filter</h2>
</div>
</div>
<label className="field">
<span>Versions, one per line</span>
<textarea
aria-label="Semantic versions"
value={versions}
onChange={(event) => setVersions(event.target.value)}
/>
</label>
<label className="field">
<span>Basic range</span>
<input
aria-label="Semantic version range"
value={range}
onChange={(event) => setRange(event.target.value)}
/>
</label>
<p className="muted">
Supports exact/partial/wildcard versions, &lt;/≤/&gt;/
comparators, whitespace AND, || OR, hyphen, tilde, and caret
ranges. Build metadata does not affect precedence.
</p>
</section>
<section className="panel workspace">
<table>
<thead>
<tr>
<th>Version (descending)</th>
<th>Normalized</th>
<th>Range</th>
</tr>
</thead>
<tbody>
{versionRows.map((row, index) => (
<tr key={`${row.value}-${index}`}>
<td>
<code>{row.value}</code>
</td>
<td>
{row.error ||
(row.parsed
? `${row.parsed.major}.${row.parsed.minor}.${row.parsed.patch}${row.parsed.prerelease.length ? `-${row.parsed.prerelease.join(".")}` : ""}`
: "")}
</td>
<td>
{row.error
? "invalid"
: row.matches
? "matches"
: "outside"}
</td>
</tr>
))}
</tbody>
</table>
</section>
</div>
)}
{tab === "commit" && (
<div className="split-layout">
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Conventional Commits</p>
<h2>Message builder</h2>
</div>
<button type="button" className="primary" onClick={buildCommit}>
Build message
</button>
</div>
<div className="form-grid">
<label className="field">
<span>Type</span>
<select
value={type}
onChange={(event) => setType(event.target.value)}
>
{CONVENTIONAL_TYPES.map((value) => (
<option key={value}>{value}</option>
))}
</select>
</label>
<label className="field">
<span>Scope</span>
<input
value={scope}
onChange={(event) => setScope(event.target.value)}
/>
</label>
<label className="field wide">
<span>Subject</span>
<input
value={subject}
onChange={(event) => setSubject(event.target.value)}
/>
</label>
<label className="check wide">
<input
type="checkbox"
checked={breaking}
onChange={(event) => setBreaking(event.target.checked)}
/>{" "}
Breaking change
</label>
<label className="field wide">
<span>Body</span>
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
/>
</label>
<label className="field wide">
<span>Breaking change description</span>
<input
value={breakingDescription}
onChange={(event) =>
setBreakingDescription(event.target.value)
}
disabled={!breaking}
/>
</label>
<label className="field wide">
<span>Issue references</span>
<input
value={references}
onChange={(event) => setReferences(event.target.value)}
placeholder="#123, PROJ-4"
/>
</label>
</div>
</section>
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Live lint</p>
<h2>{linted.valid ? "Structurally valid" : "Needs changes"}</h2>
</div>
<span>{linted.breaking ? "Breaking" : "Compatible"}</span>
</div>
<textarea
aria-label="Commit message"
value={message}
onChange={(event) => setMessage(event.target.value)}
/>
<dl className="metadata">
<div>
<dt>Type / scope</dt>
<dd>
{linted.type || "—"} / {linted.scope || "—"}
</dd>
</div>
<div>
<dt>Subject</dt>
<dd>{linted.subject || "—"}</dd>
</div>
<div>
<dt>Footers</dt>
<dd>{linted.footers.join("; ") || "—"}</dd>
</div>
</dl>
{linted.issues.length ? (
<ul className="diagnostics">
{linted.issues.map((issue) => (
<li key={issue.message} data-severity={issue.severity}>
{issue.severity}: {issue.message}
</li>
))}
</ul>
) : (
<p className="success">No configured structural issues.</p>
)}
</section>
</div>
)}
{tab === "changelog" && (
<div className="split-layout">
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Keep a Changelog shape</p>
<h2>Changelog source</h2>
</div>
<button type="button" className="primary" onClick={runChangelog}>
Normalize
</button>
</div>
<textarea
aria-label="Changelog source"
value={changelog}
onChange={(event) => setChangelog(event.target.value)}
/>
</section>
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Deterministic output</p>
<h2>
{normalized.releases} releases · {normalized.deduplicated}{" "}
duplicates removed
</h2>
</div>
<button
type="button"
onClick={() =>
download(normalized.output, "CHANGELOG.md", "text/markdown")
}
>
Download Markdown
</button>
</div>
<textarea
aria-label="Normalized changelog"
value={normalized.output}
readOnly
/>
{normalized.diagnostics.length > 0 && (
<ul className="diagnostics">
{normalized.diagnostics.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</section>
</div>
)}
</main>
);
}
+1002
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, { scope: new URL("./", document.baseURI).pathname })
.catch(() => undefined);
});
}
+575
View File
@@ -0,0 +1,575 @@
:root {
--toolbox-background: #f6f7fb;
--toolbox-surface: #fff;
--toolbox-surface-soft: #eff1f7;
--toolbox-text: #202332;
--toolbox-muted: #656b7d;
--toolbox-border: #d9dce7;
--toolbox-accent: #5b4ec4;
--toolbox-accent-hover: #493caf;
--toolbox-accent-soft: #ece9ff;
--toolbox-accent-contrast: #fff;
--toolbox-focus: #137d75;
--toolbox-danger: #b42342;
}
* {
box-sizing: border-box;
}
html {
min-width: 20rem;
min-height: 100%;
background: var(--toolbox-background);
scrollbar-gutter: stable;
}
body {
min-width: 20rem;
min-height: 100vh;
margin: 0;
background: var(--toolbox-background);
color: var(--toolbox-text);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
.button {
min-height: 2.55rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
font-weight: 720;
cursor: pointer;
}
button:hover:not(:disabled),
.button:hover {
border-color: var(--toolbox-accent);
background: var(--toolbox-surface-soft);
}
:where(button, input, select, textarea, a):focus-visible {
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
outline-offset: 2px;
}
input,
select,
textarea {
width: 100%;
min-height: 2.55rem;
padding: 0.58rem 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
textarea {
min-height: 10rem;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
line-height: 1.48;
}
.toolbox-shell__main {
width: min(100%, 90rem);
padding: clamp(0.75rem, 1.8vw, 1.5rem);
}
.workbench {
display: grid;
gap: 1rem;
}
.hero,
.panel {
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
}
.hero {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
padding: clamp(1.1rem, 3vw, 2rem);
}
.hero h1,
.panel h2,
.panel h3,
.help-dialog h2,
.fatal h1 {
margin: 0;
letter-spacing: -0.025em;
}
.hero p:not(.eyebrow) {
max-width: 52rem;
margin: 0.55rem 0 0;
color: var(--toolbox-muted);
line-height: 1.55;
}
.eyebrow {
margin: 0 0 0.3rem;
color: var(--toolbox-accent);
font-size: 0.69rem;
font-weight: 820;
letter-spacing: 0.115em;
text-transform: uppercase;
}
.privacy-pill {
flex: 0 0 auto;
padding: 0.38rem 0.62rem;
border-radius: 999px;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.75rem;
font-weight: 760;
}
.panel {
padding: 1rem;
}
.panel-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: end;
margin-bottom: 0.9rem;
}
.capability-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: 0.75rem;
}
.capability-grid article {
padding: 0.9rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.72rem;
background: var(--toolbox-surface-soft);
}
.capability-grid p {
margin: 0.4rem 0 0;
color: var(--toolbox-muted);
line-height: 1.48;
}
.workspace-tabs {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding-bottom: 0.2rem;
}
.workspace-tabs button[aria-selected="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: 0.85rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field > span {
font-size: 0.76rem;
font-weight: 750;
}
.muted {
color: var(--toolbox-muted);
}
.result {
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.68rem;
background: var(--toolbox-surface-soft);
overflow-wrap: anywhere;
}
.loading,
.fatal {
width: min(100% - 2rem, 60rem);
margin: 2rem auto;
padding: 1rem;
}
.help-dialog {
width: min(36rem, calc(100% - 2rem));
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
.help-dialog::backdrop {
background: rgb(20 24 45 / 55%);
}
.dialog-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
}
.workspace {
display: grid;
gap: 0.85rem;
}
.editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.editor-grid textarea {
min-height: 22rem;
}
.encoding-row {
display: grid;
grid-template-columns: minmax(10rem, 16rem) minmax(12rem, 1fr);
gap: 0.7rem;
align-items: end;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.primary {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.file-button {
position: relative;
overflow: hidden;
}
.file-button input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.check {
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 2.55rem;
}
.check input {
width: auto;
min-height: auto;
}
.inventory {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: 0.45rem;
margin: 0;
}
.inventory div {
padding: 0.55rem;
border-radius: 0.55rem;
background: var(--toolbox-surface-soft);
}
.inventory dt {
color: var(--toolbox-muted);
font-size: 0.66rem;
font-weight: 750;
}
.inventory dd {
margin: 0.15rem 0 0;
}
.steps {
display: grid;
gap: 0.55rem;
margin: 0;
padding: 0;
list-style: none;
}
.steps li {
display: grid;
grid-template-columns: minmax(12rem, 0.8fr) minmax(12rem, 1.4fr) auto;
gap: 0.6rem;
align-items: center;
padding: 0.6rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
}
.step-actions {
display: flex;
gap: 0.25rem;
}
.step-actions button {
min-width: 2.55rem;
padding: 0.4rem;
}
.warning,
.error,
.notice {
margin: 0;
padding: 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
line-height: 1.5;
}
.warning {
border-color: #d9a72e;
background: #fff8df;
color: #725000;
}
.error {
border-color: var(--toolbox-danger);
color: var(--toolbox-danger);
}
.notice {
background: var(--toolbox-surface-soft);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.55rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
}
th {
color: var(--toolbox-muted);
font-size: 0.7rem;
text-transform: uppercase;
}
details {
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
}
summary {
padding: 0.65rem;
cursor: pointer;
font-weight: 750;
}
.recipe {
display: grid;
gap: 0.6rem;
padding: 0 0.65rem 0.65rem;
}
.alert,
.success {
margin: 0;
padding: 0.75rem 0.9rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.68rem;
}
.alert {
border-color: var(--toolbox-danger);
color: var(--toolbox-danger);
background: color-mix(
in srgb,
var(--toolbox-danger) 7%,
var(--toolbox-surface)
);
}
.success {
color: #187147;
background: color-mix(in srgb, #2ba46a 10%, var(--toolbox-surface));
}
.split-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
align-items: start;
}
.split-layout textarea {
min-height: 29rem;
}
.facts {
display: grid;
grid-template-columns: repeat(4, minmax(6rem, 1fr));
gap: 0.55rem;
}
.facts > div {
display: grid;
gap: 0.2rem;
padding: 0.7rem;
border-radius: 0.6rem;
background: var(--toolbox-surface-soft);
}
.facts span,
.file-list header span,
.file-list article > p,
.metadata dt {
color: var(--toolbox-muted);
font-size: 0.74rem;
}
.added {
color: #187147;
}
.deleted {
color: var(--toolbox-danger);
}
.file-list,
.result-list {
display: grid;
gap: 0.65rem;
}
.file-list > article,
.result-list > article {
border: 1px solid var(--toolbox-border);
border-radius: 0.7rem;
overflow: hidden;
}
.file-list article > header,
.result-list article > header {
display: flex;
justify-content: space-between;
gap: 0.6rem;
align-items: start;
padding: 0.7rem;
background: var(--toolbox-surface-soft);
}
.file-list header > div {
display: grid;
gap: 0.2rem;
}
.file-list article > p,
.result-list article > p,
.result-list article > ol {
margin: 0;
padding: 0.7rem;
}
.patch-lines {
max-height: 25rem;
overflow: auto;
font:
0.78rem/1.45 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
}
.patch-lines > div {
display: grid;
grid-template-columns: 3.3rem 3.3rem minmax(max-content, 1fr);
min-width: max-content;
}
.patch-lines > div > span {
padding: 0.1rem 0.35rem;
border-right: 1px solid var(--toolbox-border);
color: var(--toolbox-muted);
text-align: right;
user-select: none;
}
.patch-lines code {
padding: 0.1rem 0.45rem;
white-space: pre;
}
.patch-lines [data-kind="add"] {
background: color-mix(in srgb, #2ba46a 14%, var(--toolbox-surface));
}
.patch-lines [data-kind="delete"] {
background: color-mix(
in srgb,
var(--toolbox-danger) 11%,
var(--toolbox-surface)
);
}
.diagnostics {
max-height: 18rem;
overflow: auto;
line-height: 1.5;
}
.diagnostics [data-severity="error"] {
color: var(--toolbox-danger);
}
.author-controls {
display: grid;
grid-template-columns: minmax(14rem, 1fr) minmax(8rem, 12rem);
gap: 0.7rem;
}
.triple-editors {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.triple-editors textarea {
min-height: 30rem;
}
.result-list [data-ignored="true"] {
border-left: 5px solid var(--toolbox-danger);
}
.result-list [data-ignored="false"] {
border-left: 5px solid #2ba46a;
}
.result-list code,
table code {
overflow-wrap: anywhere;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.65rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
vertical-align: top;
}
th {
color: var(--toolbox-muted);
font-size: 0.7rem;
text-transform: uppercase;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.wide {
grid-column: 1 / -1;
}
.form-grid textarea {
min-height: 8rem;
}
.metadata {
display: grid;
gap: 0.45rem;
margin: 0;
}
.metadata > div {
display: grid;
grid-template-columns: minmax(7rem, 0.3fr) minmax(0, 1fr);
gap: 0.5rem;
}
.metadata dd {
margin: 0;
overflow-wrap: anywhere;
}
@media (max-width: 62rem) {
.editor-grid {
grid-template-columns: 1fr;
}
.split-layout,
.triple-editors {
grid-template-columns: 1fr;
}
}
@media (max-width: 48rem) {
.steps li {
grid-template-columns: 1fr;
}
}
@media (max-width: 42rem) {
.hero {
flex-direction: column;
}
.privacy-pill {
order: -1;
}
.facts,
.author-controls,
.form-grid {
grid-template-columns: 1fr;
}
.wide {
grid-column: auto;
}
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
localStorage.clear();
});
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.git-tools",
"name": "Git Tools",
"version": "0.1.0",
"description": "Inspect and author Git interchange formats locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["developer", "text", "productivity"],
"tags": [
"git",
"patch",
"diff",
"gitignore",
"semver",
"conventional-commits",
"changelog"
],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": true,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/git-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/git-tools"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />