Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Requires **macOS 14+** and **Xcode Command Line Tools** (`xcode-select --install

### Workflow
- **Tabs and multi-window** — open many files in one window (`⌘O` adds a tab) or pop a new window with `⌘N`. Each tab carries its own scroll, theme, font scale, and collaborator registry. `⌘W` closes the active tab when more than one is open, otherwise the window.
- **SSH profiles** — the network toolbar button opens the favorite root from `~/Library/Application Support/Mindle/ssh-profiles.yaml`. The generated template is disabled until edited, remote listings fail if their configured root is missing, and Markdown plus allowlisted referenced images are cached in a host-keyed local mirror.
- **Open remote URLs** — `⌘⇧L` opens any `http(s)` URL pointing at raw Markdown (a GitHub raw link, a hosted doc) as a tab. Annotations on URL documents persist locally keyed by URL hash, so re-opening the same URL brings the annotations back.
- **Open from Clipboard** — `⌘⇧V` opens the pasteboard contents as a Markdown tab. Use this when the source is behind a login your browser already handles (internal GitLab raw, Confluence, anything auth-walled): copy the raw Markdown there, paste it here. Tabs are content-addressed, so re-pasting identical text re-opens the same tab with its prior annotations.
- **File browser** — scoped sidebar tree of every `.md` and `.txt` in the current folder (`⌘⇧F`). Never escapes upward.
Expand Down
72 changes: 42 additions & 30 deletions Resources/web/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -1219,47 +1219,59 @@
function rewriteImages() {
const imgs = doc.querySelectorAll("img");
imgs.forEach(img => {
const src = img.getAttribute("src") || "";
const res = resolveImageSrc(src);
if (res.blocked) {
const ph = document.createElement("span");
ph.className = "mindle-img-blocked";
ph.textContent = "[remote image hidden — " + (img.alt || src) + "]";
img.replaceWith(ph);
} else if (res.url !== null && res.url !== src) {
img.setAttribute("src", res.url);
img.addEventListener("error", () => {
const ph = document.createElement("span");
ph.className = "mindle-img-missing";
ph.textContent = "[image not found — " + (img.alt || src) + "]";
img.replaceWith(ph);
});
} else if (res.url !== null) {
// Left as-is (data: URL etc.) — still add broken-image handler.
img.addEventListener("error", () => {
const ph = document.createElement("span");
ph.className = "mindle-img-missing";
ph.textContent = "[image not found — " + (img.alt || src) + "]";
img.replaceWith(ph);
});
let src = "";
try {
src = img.getAttribute("src") || "";
const res = resolveImageSrc(src);
if (res.blocked) {
replaceImageWithPlaceholder(img, "mindle-img-blocked", "remote image hidden", src);
} else if (res.url !== null) {
if (res.url !== src) img.setAttribute("src", res.url);
img.addEventListener("error", () => {
replaceImageWithPlaceholder(img, "mindle-img-missing", "image not found", src);
});
}
} catch (error) {
console.error("Mindle couldn't rewrite an image source.", { src, error });
replaceImageWithPlaceholder(img, "mindle-img-missing", "invalid image path", src);
}
});
}

function replaceImageWithPlaceholder(img, className, message, src) {
const ph = document.createElement("span");
ph.className = className;
ph.textContent = "[" + message + " — " + (img.alt || src || "image") + "]";
img.replaceWith(ph);
}

function resolveImageSrc(src) {
if (!src) return { url: null };
if (src.startsWith("data:")) return { url: src };
if (/^https?:/i.test(src)) return { blocked: true };
const pathSrc = decodeImagePath(src);
if (/^file:\/\//i.test(src)) {
const path = src.replace(/^file:\/\//i, "");
return { url: "mindle-file://" + path };
const path = pathSrc.replace(/^file:\/\//i, "");
return { url: "mindle-file://" + encodeImagePath(path) };
}
if (pathSrc.startsWith("/")) {
return { url: "mindle-file://" + encodeImagePath(pathSrc) };
}
if (src.startsWith("/")) {
return { url: "mindle-file://" + encodeURI(src) };
if (!baseDir) return { url: pathSrc };
const resolved = resolveRelativePath(baseDir, pathSrc);
return { url: "mindle-file://" + encodeImagePath(resolved) };
}

function decodeImagePath(src) {
try {
return decodeURIComponent(src);
} catch (_) {
return src;
}
if (!baseDir) return { url: src };
const resolved = resolveRelativePath(baseDir, src);
return { url: "mindle-file://" + encodeURI(resolved) };
}

function encodeImagePath(path) {
return path.split("/").map(encodeURIComponent).join("/");
}

function resolveRelativePath(base, rel) {
Expand Down
82 changes: 72 additions & 10 deletions Sources/mindle/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct ContentView: View {
// the material bleeds to the window's system-gray backing.
c.background.ignoresSafeArea()

if store.fileURL == nil {
if store.fileURL == nil && !store.fileBrowserHasRoot {
EmptyStateView()
} else {
VStack(spacing: 0) {
Expand All @@ -25,8 +25,13 @@ struct ContentView: View {
FileBrowserSidebar()
.frame(minWidth: 200, idealWidth: 260, maxWidth: 400)
}
ReaderPane()
.frame(minWidth: 480)
if store.fileURL == nil {
RemoteBrowserPlaceholder()
.frame(minWidth: 480)
} else {
ReaderPane()
.frame(minWidth: 480)
}
if store.showAnnotations {
AnnotationsSidebar()
.frame(minWidth: 280, idealWidth: 340, maxWidth: 460)
Expand All @@ -36,6 +41,16 @@ struct ContentView: View {
}
}
.toolbar {
ToolbarItem(placement: .navigation) {
Button {
Task { await store.openFavoriteSSHProfile() }
} label: {
Image(systemName: "network")
.foregroundStyle(c.text)
}
.help("Open favorite SSH profile")
}

ToolbarItem(placement: .navigation) {
Button { store.openWithPanel() } label: {
Image(systemName: "doc.text")
Expand All @@ -57,7 +72,7 @@ struct ContentView: View {
.foregroundStyle(store.showFileBrowser ? c.accent : c.muted)
}
.help("Toggle files (⌘⇧F)")
.disabled(store.fileURL == nil)
.disabled(store.fileURL == nil && !store.fileBrowserHasRoot)
}

ToolbarItem(placement: .principal) {
Expand Down Expand Up @@ -157,6 +172,27 @@ struct ContentView: View {
}
}

private struct RemoteBrowserPlaceholder: View {
@EnvironmentObject var store: DocumentStore

var body: some View {
let c = store.theme.colors
VStack(spacing: 12) {
Image(systemName: "network")
.font(.system(size: 42, weight: .ultraLight))
.foregroundStyle(c.muted)
Text("Choose a remote document")
.font(.system(size: 18, design: .serif))
.foregroundStyle(c.text)
Text("Files stay confined to the configured SSH profile root.")
.font(.system(size: 12, design: .serif))
.foregroundStyle(c.muted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(c.background)
}
}

// MARK: - Button styles

struct ToolChipStyle: ButtonStyle {
Expand Down Expand Up @@ -1293,9 +1329,9 @@ struct FileBrowserSidebar: View {
let c = store.theme.colors
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "folder")
Image(systemName: store.fileTree?.url.isMindleSSH == true ? "network" : "folder")
.foregroundStyle(c.accent)
Text("Files")
Text(store.fileBrowserTitle)
.font(.system(size: 13, weight: .semibold, design: .serif))
.foregroundStyle(c.text)
Spacer()
Expand All @@ -1314,7 +1350,28 @@ struct FileBrowserSidebar: View {

Rectangle().fill(c.rule.opacity(0.4)).frame(height: 0.5)

if let tree = store.fileTree, let children = tree.children, !children.isEmpty {
if store.fileBrowserIsLoading {
VStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Loading remote documents…")
.font(.system(size: 12, design: .serif).italic())
.foregroundStyle(c.muted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error = store.fileBrowserError {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 24, weight: .ultraLight))
.foregroundStyle(c.muted)
Text(error)
.multilineTextAlignment(.center)
.font(.system(size: 12, design: .serif))
.foregroundStyle(c.muted)
.padding(.horizontal, 20)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let tree = store.fileTree, let children = tree.children, !children.isEmpty {
ScrollView {
// Non-lazy VStack so the tree's content size stays constant
// when other window state changes (e.g. fileURL flipping
Expand All @@ -1336,7 +1393,7 @@ struct FileBrowserSidebar: View {
Image(systemName: "tray")
.font(.system(size: 28, weight: .ultraLight))
.foregroundStyle(c.muted.opacity(0.7))
Text("No markdown files\nin this directory.")
Text("No supported documents\nin this directory.")
.multilineTextAlignment(.center)
.font(.system(size: 12, design: .serif).italic())
.foregroundStyle(c.muted)
Expand Down Expand Up @@ -1390,9 +1447,14 @@ struct FileTreeRow: View {
}
}
} else {
let isCurrent = store.fileURL?.standardizedFileURL == node.url.standardizedFileURL
let activeSourceURL = store.activeTabID.flatMap { activeID in
store.tabs.first(where: { $0.id == activeID })?.sourceURL
}
let isCurrent = node.url.isMindleSSH
? activeSourceURL == node.url
: store.fileURL?.standardizedFileURL == node.url.standardizedFileURL
Button {
store.open(url: node.url)
store.openBrowserItem(node.url)
} label: {
HStack(spacing: 6) {
Spacer().frame(width: 10)
Expand Down
Loading