Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Describe and preserve what is already true in the repo today.

- The app renders as one export-friendly shell.
- The empty state explains the product and exposes sample fragment presets.
- A built-in link creator can generate fragment-based links locally in the browser.
- A built-in link creator can generate fragment-based links locally in the browser. The load-body step accepts pasted text or a local file read in the browser; the file is not sent to the host.
- When a valid fragment is present, the app switches to a viewer-first artifact layout.
- The artifact stage toolbar exposes copy-to-clipboard, file download, (for markdown) browser print-to-PDF, and an editor that regenerates a new fragment link from the currently open artifact.
- `activeArtifactId` controls which artifact opens first.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

### Added

- Link creator can load a local text file into the draft (client-side only) instead of requiring a paste.
- In-viewer edit-and-reshare: open any artifact, correct its content, and generate a new fragment link without leaving the viewer.
- `arx5` (ARX 4.5): ARX4's context mixer on ARX2's tuple pipeline, scored by honest serialized transport length so Discord and WhatsApp no longer detonate Unicode wires.

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The static export also emits `sitemap.xml` at the site root (and under `NEXT_PUB

The viewer shell now routes all five artifact kinds through dynamically imported client-only renderers so the landing shell stays light and static-host friendly.

When a valid fragment is present, the shell switches into a viewer-first layout with bundle navigation beside the active artifact. The active artifact header includes copy, download, markdown print, and edit-and-reshare actions. Edit regenerates a new fragment link from the current artifact (or the current artifact inside a bundle) without writing anything to a server. The landing/samples experience is only the empty state.
When a valid fragment is present, the shell switches into a viewer-first layout with bundle navigation beside the active artifact. The active artifact header includes copy, download, markdown print, and edit-and-reshare actions. Edit regenerates a new fragment link from the current artifact (or the current artifact inside a bundle) without writing anything to a server. The landing/samples experience is only the empty state. The empty-state link creator can paste content or read a local text file in the browser before compressing it into a fragment; the file never enters the request path.

Diff file navigation is intentionally internal UI state now. The URL fragment remains reserved for payload transport and active-artifact selection instead of being reused as an in-page file anchor system.

Expand Down
29 changes: 29 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ button:disabled {
.artifact-switcher:focus-visible,
.creator-input:focus-visible,
.creator-textarea:focus-visible,
.creator-file-input:focus-visible,
.artifact-disclosure-summary:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
Expand Down Expand Up @@ -603,6 +604,34 @@ button:disabled {
white-space: nowrap;
}

.creator-file-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}

.creator-file-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
Comment thread
cursor[bot] marked this conversation as resolved.
white-space: nowrap;
}

.creator-file-note {
color: var(--on-chassis-muted);
font-size: 11px;
line-height: 1.35;
}

.creator-field.is-drop-target .creator-textarea {
border-color: var(--control-accent);
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.44), 0 0 0 1px var(--control-accent);
}

.creator-input,
.creator-textarea {
width: 100%;
Expand Down
182 changes: 171 additions & 11 deletions src/components/home/link-creator.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
"use client";

import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link2 } from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState, type DragEvent } from "react";
import { Link2, Upload } from "lucide-react";
import { copyTextToClipboard } from "@/lib/copy-text";
import { CODE_LANGUAGE_CHOICES } from "@/lib/code/language";
import { CodecPicker, GeneratedLinkResult } from "@/components/generated-link";
import type {
GeneratedArtifactLink,
LinkCreatorDraft,
} from "@/lib/payload/link-creator";
import {
assertReadableLocalArtifactFile,
createDraftFromLocalFile,
LOCAL_ARTIFACT_FILE_ACCEPT,
} from "@/lib/payload/local-file";
import { artifactKinds, type ArtifactKind } from "@/lib/payload/schema";
import { cn } from "@/lib/utils";

Expand All @@ -17,11 +22,11 @@ type LinkCreatorProps = {
};

const fieldHints: Record<ArtifactKind, string> = {
markdown: "Paste markdown notes, release docs, or a spec excerpt.",
code: "Paste a code snippet and add a language hint when known.",
diff: "Paste a unified git patch.",
csv: "Paste comma-separated headings and rows.",
json: "Paste formatted or compact JSON.",
markdown: "Paste notes or load a local markdown file.",
code: "Paste a snippet or load a local source file.",
diff: "Paste a unified git patch or load a .diff / .patch file.",
csv: "Paste rows or load a local CSV file.",
json: "Paste JSON or load a local .json file.",
};

const fieldPlaceholders: Record<ArtifactKind, string> = {
Expand Down Expand Up @@ -58,7 +63,8 @@ function getBodyFieldLabel(kind: ArtifactKind) {
}

/**
* Builds shareable fragment links from pasted artifact content in the home empty state flow.
* Builds shareable fragment links from pasted or locally loaded artifact content
* in the home empty state flow. File selection is read in the browser only.
* Accepts `onPreviewHash` so the parent shell can preview the generated fragment before navigation.
* Generates links client-side with validation, and exposes inline copy/error/stale-result states.
*/
Expand All @@ -80,6 +86,11 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) {
const generationRequestRef = useRef(0);
const markdownCopyTokenRef = useRef(0);
const generatedLinkRef = useRef<GeneratedArtifactLink | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const fileLoadRequestRef = useRef(0);
const draftRef = useRef(draft);
const fileDragDepthRef = useRef(0);
const [isFileDragOver, setIsFileDragOver] = useState(false);
const isGeneratedLinkStale =
Boolean(generatedLink) && draftVersion !== generatedVersion;
const contentFieldLabel = getBodyFieldLabel(draft.kind);
Expand All @@ -88,6 +99,10 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) {
generatedLinkRef.current = generatedLink;
}, [generatedLink]);

useLayoutEffect(() => {
draftRef.current = draft;
}, [draft]);

useEffect(() => {
setCopyState("idle");
setMarkdownLinkCopyState("idle");
Expand All @@ -113,6 +128,110 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) {
});
};

const replaceDraft = (nextDraft: LinkCreatorDraft) => {
setDraftState((current) => {
if (
current.draft.kind === nextDraft.kind &&
current.draft.title === nextDraft.title &&
current.draft.filename === nextDraft.filename &&
current.draft.content === nextDraft.content &&
current.draft.language === nextDraft.language &&
current.draft.diffView === nextDraft.diffView &&
current.draft.codec === nextDraft.codec
) {
return current;
}

return {
draft: nextDraft,
version: current.version + 1,
};
});
};

const handleLocalFile = async (file: File) => {
const requestId = fileLoadRequestRef.current + 1;
fileLoadRequestRef.current = requestId;

try {
assertReadableLocalArtifactFile(file);
const text = await file.text();
if (fileLoadRequestRef.current !== requestId) {
return;
}

replaceDraft(
createDraftFromLocalFile(
Comment thread
cursor[bot] marked this conversation as resolved.
{
name: file.name,
size: file.size,
type: file.type,
text,
},
draftRef.current,
),
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
setError(null);
} catch (loadError) {
if (fileLoadRequestRef.current !== requestId) {
return;
}

setError(
Comment thread
cursor[bot] marked this conversation as resolved.
loadError instanceof Error
? loadError.message
: "The file could not be read.",
);
}
};

const resetFileDragState = () => {
fileDragDepthRef.current = 0;
setIsFileDragOver(false);
};

const handleFileDragEnter = (event: DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}

event.preventDefault();
fileDragDepthRef.current += 1;
setIsFileDragOver(true);
};

const handleFileDragOver = (event: DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}

event.preventDefault();
event.dataTransfer.dropEffect = "copy";
};

const handleFileDragLeave = (event: DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes("Files")) {
return;
}

event.preventDefault();
fileDragDepthRef.current -= 1;
if (fileDragDepthRef.current <= 0) {
resetFileDragState();
}
};

const handleFileDrop = (event: DragEvent<HTMLDivElement>) => {
const file = event.dataTransfer.files.item(0);
resetFileDragState();
if (!file) {
return;
}

event.preventDefault();
void handleLocalFile(file);
};

const handleGenerate = async () => {
const requestId = generationRequestRef.current + 1;
generationRequestRef.current = requestId;
Expand Down Expand Up @@ -306,20 +425,61 @@ export function LinkCreator({ onPreviewHash }: LinkCreatorProps) {
<span className="operation-number">03</span>
<h3>Load body</h3>
</header>
<label className="creator-field creator-field-full">
<div
className={cn(
"creator-field creator-field-full",
isFileDragOver && "is-drop-target",
)}
onDragEnter={handleFileDragEnter}
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
>
<span className="creator-field-head">
<span className="metric-label">{contentFieldLabel}</span>
<label htmlFor="creator-content" className="metric-label">
{contentFieldLabel}
</label>
<span className="creator-field-hint">{fieldHints[draft.kind]}</span>
</span>
<div className="creator-file-row">
<input
ref={fileInputRef}
id="creator-file"
type="file"
accept={LOCAL_ARTIFACT_FILE_ACCEPT}
className="creator-file-input"
tabIndex={-1}
aria-label="Load a local file"
onChange={(event) => {
const file = event.target.files?.item(0);
event.target.value = "";
if (file) {
void handleLocalFile(file);
}
}}
/>
<button
type="button"
className="artifact-action"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-3.5 w-3.5" />
Load file
</button>
<span className="creator-file-note">
Stays in the browser. Nothing is uploaded.
</span>
</div>
<textarea
id="creator-content"
name="content"
value={draft.content}
onChange={(event) => updateDraft("content", event.target.value)}
placeholder={fieldPlaceholders[draft.kind]}
className="creator-textarea"
rows={12}
/>
</label>
</div>
</section>

<section className="operation-step">
Expand Down
6 changes: 6 additions & 0 deletions src/lib/code/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export function detectCodeLanguage(filename?: string, explicit?: string) {
if (lower.endsWith(".md") || lower.endsWith(".mdx")) return "markdown";
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
if (lower.endsWith(".sh") || lower.endsWith(".bash")) return "shell";
if (lower.endsWith(".rs")) return "rust";
if (lower.endsWith(".go")) return "go";
if (lower.endsWith(".java")) return "java";
if (lower.endsWith(".c") || lower.endsWith(".h")) return "c";
if (lower.endsWith(".cpp") || lower.endsWith(".cc") || lower.endsWith(".hpp")) return "cpp";
if (lower.endsWith(".sql")) return "sql";

return "text";
}
Expand Down
Loading
Loading