diff --git a/docs/at-proto.md b/docs/at-proto.md
index 82ce3af..493920c 100644
--- a/docs/at-proto.md
+++ b/docs/at-proto.md
@@ -144,7 +144,8 @@ src/
│ └── useAtProtoController.ts
├── components/
│ ├── AtProto/
-│ │ └── AtProtoAuthSheet.tsx # login, session, and import browser sheet
+│ │ ├── AtProtoAuthSheet.tsx # login + session sheet
+│ │ └── ImportSheet.tsx # Tangled import browser sheet
│ └── AppLayout/LayoutSettingsPanel/
│ └── AtProtoSection.tsx
```
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 425d53b..a192f1b 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -5,7 +5,7 @@ last_updated: 2026-03-19
## Tangled string integration
-Publish documents as [Tangled strings](https://tangled.sh) (AT Protocol gists) and import strings as documents. Spec in [at-proto.md](./at-proto.md).
+Publish documents as [Tangled strings](https://tangled.sh) (AT Protocol gists) and import strings as documents.
### Part 1 — Auth
@@ -26,8 +26,6 @@ Publish documents as [Tangled strings](https://tangled.sh) (AT Protocol gists) a
- Extensions covered: `py`, `md`, `js`, `ts`, `yaml`, `java`, `sass`, `css`, `csv`, `fs`, `cs`
- `i-fluent-document-16-filled` for fallback
-Implemented on `2026-03-19` in the Tauri backend, AT Proto controller, and unified auth/import sheet.
-
### Part 3 — Push
1. **Tauri commands** - `string_create`, `string_update`, `string_delete`
diff --git a/src/__tests__/Toolbar.test.tsx b/src/__tests__/Toolbar.test.tsx
index b07026c..8362931 100644
--- a/src/__tests__/Toolbar.test.tsx
+++ b/src/__tests__/Toolbar.test.tsx
@@ -60,12 +60,12 @@ describe("Toolbar", () => {
expect(setEditorOnlyMode).toHaveBeenCalledOnce();
});
- it("opens the Tangled auth entry from the toolbar", () => {
+ it("opens the AT Protocol auth entry from the toolbar", () => {
const onAtProtoAuth = vi.fn();
render();
- fireEvent.click(screen.getByRole("button", { name: "Tangled" }));
+ fireEvent.click(screen.getByRole("button", { name: "Login" }));
expect(onAtProtoAuth).toHaveBeenCalledOnce();
});
});
diff --git a/src/components/AppLayout/WorkspacePanel.tsx b/src/components/AppLayout/WorkspacePanel.tsx
index 97ba8c2..74ced32 100644
--- a/src/components/AppLayout/WorkspacePanel.tsx
+++ b/src/components/AppLayout/WorkspacePanel.tsx
@@ -58,6 +58,7 @@ export type WorkspacePanelProps = {
| "isPdfExportDisabled"
| "onRefresh"
>;
+ onOpenImportSheet?: () => void;
editor: WorkspaceEditorProps;
preview: WorkspacePreviewProps;
statusBar: StatusBarProps;
@@ -156,7 +157,7 @@ function Section({ children, initial, animate, exit, transition, className, styl
);
}
-export function WorkspacePanel({ toolbar, editor, preview, statusBar, diagnostics }: WorkspacePanelProps) {
+export function WorkspacePanel({ toolbar, onOpenImportSheet, editor, preview, statusBar, diagnostics }: WorkspacePanelProps) {
const skipAnimation = useSkipAnimation();
const { viewportWidth } = useViewportTier(FALLBACK_VIEWPORT_WIDTH);
const { sidebarCollapsed } = useWorkspacePanelSidebarState();
@@ -273,7 +274,7 @@ export function WorkspacePanel({ toolbar, editor, preview, statusBar, diagnostic
{...sidebarMotionProps}
className="relative flex h-full shrink-0"
style={sidebarStyle}>
-
+
;
@@ -137,217 +137,27 @@ function SessionView({ controller }: { controller: Controller }) {
);
}
-function RecordRow(
- { record, isSelected, onSelectTid }: {
- record: TangledStringRecord;
- isSelected: boolean;
- onSelectTid: (tid: string) => void;
- },
-) {
- const handleClick = useCallback(() => {
- onSelectTid(record.tid);
- }, [onSelectTid, record.tid]);
-
- return (
-
- );
-}
-
-function BrowseHandleForm({ controller }: { controller: Controller }) {
- const handleChange = useCallback
>((event) => {
- controller.setImportHandle(event.target.value);
- }, [controller]);
-
- return (
-
-
-
- Public strings can be imported without signing in. The browser defaults to your connected handle when available.
-
-
- );
-}
-
-function RecordsPanel({ controller }: { controller: Controller }) {
- const handleSelectTid = useCallback((tid: string) => {
- controller.handleSelectString(tid);
- }, [controller]);
-
- return (
-
-
- {controller.importState.browseHandle ? `Strings for ${controller.importState.browseHandle}` : "Strings"}
-
-
- {controller.importState.records.length === 0
- ? (
-
- {controller.importState.isListing
- ? "Loading Tangled strings..."
- : "No strings loaded yet. Enter a handle and browse."}
-
- )
- : controller.importState.records.map((record) => (
-
- ))}
-
-
- );
-}
-
-function ImportDestinationForm({ controller }: { controller: Controller }) {
- const handleLocationChange = useCallback>((event) => {
- controller.setDestinationLocationId(Number(event.target.value) || null);
- }, [controller]);
- const handlePathChange = useCallback>((event) => {
- controller.setDestinationRelPath(event.target.value);
- }, [controller]);
- const handleImport = useCallback(() => {
- void controller.handleImport();
- }, [controller]);
- const importDisabled = controller.importState.isSaving
- || !controller.importState.selectedRecord
- || !controller.importState.destinationLocationId
- || !controller.importState.destinationRelPath.trim()
- || !controller.hasLocations;
-
- return (
-
-
-
-
- Non-Markdown strings are imported as fenced code blocks so the resulting document stays readable in Writer.
-
-
-
-
-
- );
-}
-
-function PreviewPanel({ controller }: { controller: Controller }) {
- return (
-
-
- Preview
-
-
- {controller.importState.isFetching
- ? "Loading string preview..."
- : controller.importState.previewText || "Select a string to preview the imported document body."}
-
-
- );
-}
-
-function SelectedRecordSummary({ controller }: { controller: Controller }) {
- const selectedFilename = controller.importState.selectedRecord?.filename ?? "Nothing selected";
-
- return (
-
-
{selectedFilename}
-
- {controller.importState.selectedRecord?.description || "Select a string to inspect its contents before import."}
-
-
- );
-}
-
-function ImportView({ controller }: { controller: Controller }) {
+export function AtProtoAuthSheet({ controller }: AtProtoAuthSheetProps) {
return (
<>
-
-
-
-
-
+
+
+ {controller.sheetMode === "session" && }
+ {controller.sheetMode === "login" && }
-
-
+
+
>
);
}
-
-export function AtProtoAuthSheet({ controller }: AtProtoAuthSheetProps) {
- return (
-
-
- {controller.sheetMode === "session" && }
- {controller.sheetMode === "import" && }
- {controller.sheetMode === "login" && }
-
-
- );
-}
diff --git a/src/components/AtProto/ImportSheet.tsx b/src/components/AtProto/ImportSheet.tsx
new file mode 100644
index 0000000..fcdb181
--- /dev/null
+++ b/src/components/AtProto/ImportSheet.tsx
@@ -0,0 +1,256 @@
+import { Button } from "$components/Button";
+import { Sheet } from "$components/Sheet";
+import type { useAtProtoController } from "$hooks/controllers/useAtProtoController";
+import { FileTypeIcon, Tangled } from "$icons";
+import type { ChangeEventHandler } from "react";
+import { useCallback, useMemo } from "react";
+
+type Controller = ReturnType;
+
+type ImportSheetProps = { controller: Controller; isOpen: boolean; onClose: () => void; onBack?: () => void };
+
+function ImportSheetTitle() {
+ return (
+
+
+
Import from Tangled
+
+ );
+}
+
+function ImportSheetHeader({ onBack }: { onBack?: () => void }) {
+ return (
+
+
+
+
+
+ Browse any public Tangled handle, preview a string, and save it into one of your locations.
+
+
+ {onBack &&
}
+
+
+ );
+}
+
+function RecordRow(
+ { record, isSelected, onSelectTid }: {
+ record: Controller["importState"]["records"][number];
+ isSelected: boolean;
+ onSelectTid: (tid: string) => void;
+ },
+) {
+ const handleClick = useCallback(() => {
+ onSelectTid(record.tid);
+ }, [onSelectTid, record.tid]);
+
+ return (
+
+ );
+}
+
+function BrowseHandleForm({ controller }: { controller: Controller }) {
+ const handleChange = useCallback>((event) => {
+ controller.setImportHandle(event.target.value);
+ }, [controller]);
+
+ return (
+
+
+
+ Public strings can be imported without signing in. The browser defaults to your connected handle when available.
+
+ {/* TODO: Add GitHub Gist import alongside Tangled browsing. */}
+
+ );
+}
+
+function RecordsPanel({ controller }: { controller: Controller }) {
+ const handleSelectTid = useCallback((tid: string) => {
+ controller.handleSelectString(tid);
+ }, [controller]);
+
+ return (
+
+
+ {controller.importState.browseHandle ? `Strings for ${controller.importState.browseHandle}` : "Strings"}
+
+
+ {controller.importState.records.length === 0
+ ? (
+
+ {controller.importState.isListing
+ ? "Loading Tangled strings..."
+ : "No strings loaded yet. Enter a handle and browse."}
+
+ )
+ : controller.importState.records.map((record) => (
+
+ ))}
+
+
+ );
+}
+
+function ImportDestinationForm({ controller }: { controller: Controller }) {
+ const handleLocationChange = useCallback>((event) => {
+ controller.setDestinationLocationId(Number(event.target.value) || null);
+ }, [controller]);
+
+ const handlePathChange = useCallback>((event) => {
+ controller.setDestinationRelPath(event.target.value);
+ }, [controller]);
+
+ const handleImport = useCallback(() => {
+ void controller.handleImport();
+ }, [controller]);
+
+ const importDisabled = useMemo(
+ () =>
+ controller.importState.isSaving
+ || !controller.importState.selectedRecord
+ || !controller.importState.destinationLocationId
+ || !controller.importState.destinationRelPath.trim()
+ || !controller.hasLocations,
+ [
+ controller.importState.isSaving,
+ controller.importState.selectedRecord,
+ controller.importState.destinationLocationId,
+ controller.importState.destinationRelPath,
+ controller.hasLocations,
+ ],
+ );
+
+ return (
+
+
+
+
+ Non-Markdown strings are imported as fenced code blocks so the resulting document stays readable in Writer.
+
+
+
+
+
+ );
+}
+
+function PreviewPanel({ controller }: { controller: Controller }) {
+ return (
+
+
+ Preview
+
+
+ {controller.importState.isFetching
+ ? "Loading string preview..."
+ : controller.importState.previewText || "Select a string to preview the imported document body."}
+
+
+ );
+}
+
+function SelectedRecordSummary({ controller }: { controller: Controller }) {
+ const selectedFilename = useMemo(() => controller.importState.selectedRecord?.filename ?? "Nothing selected", [
+ controller.importState.selectedRecord,
+ ]);
+
+ return (
+
+
{selectedFilename}
+
+ {controller.importState.selectedRecord?.description || "Select a string to inspect its contents before import."}
+
+
+ );
+}
+
+function ImportSheetBody({ controller }: { controller: Controller }) {
+ return (
+
+ );
+}
+
+export function ImportSheet({ controller, isOpen, onClose, onBack }: ImportSheetProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx
index 9413ce6..33f37ab 100644
--- a/src/components/Sidebar/Sidebar.tsx
+++ b/src/components/Sidebar/Sidebar.tsx
@@ -1,6 +1,6 @@
import { Button } from "$components/Button";
import { useSidebarActions } from "$hooks/controllers/useSidebarActions";
-import { CollapseIcon, FileAddIcon, FolderAddIcon, RefreshIcon } from "$icons";
+import { CollapseIcon, FileAddIcon, FileTextIcon, FolderAddIcon, FolderIcon, RefreshIcon, Tangled } from "$icons";
import { useSidebarState } from "$state/selectors";
import type { DocMeta } from "$types";
import { formatShortcut } from "$utils/shortcuts";
@@ -21,7 +21,7 @@ import { useSidebarInternalDnD } from "./useSidebarInternalDnD";
const EMPTY_DOCUMENTS: DocMeta[] = [];
const EMPTY_DIRECTORIES: string[] = [];
-export type SidebarProps = { onNewDocument?: (locationId?: number) => void };
+export type SidebarProps = { onNewDocument?: (locationId?: number) => void; onOpenImportSheet?: () => void };
type SidebarActionsProps = {
onAddLocation: () => void;
@@ -32,7 +32,7 @@ type SidebarActionsProps = {
refreshDisabled: boolean;
};
-type CountPillProps = { count: number; label: string; singularLabel: string };
+type CountPillProps = { count: number; kind: "location" | "document" | "directory" };
const HideSidebarButton = ({ onToggleCollapse }: { onToggleCollapse: () => void }) => (
-
-
+
+
+
+
+ {onOpenImportSheet &&
}
({
toolbar: toolbarProps,
+ onOpenImportSheet: atProto.openImportSheet,
editor: editorProps,
preview: previewProps,
statusBar: statusBarProps,
@@ -306,6 +307,7 @@ export function useWorkspaceViewController(): WorkspaceViewController {
}),
[
toolbarProps,
+ atProto.openImportSheet,
editorProps,
previewProps,
statusBarProps,