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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Replacement-workflow reliability and file navigation.
- Hardened folder-tree image handling so image assets cannot enter the editable-document `read-file`/grant path. Image rows remain selectable and can be revealed in Finder through the existing folder-scoped authority instead of being opened as Markdown.
- Added **Export with Previous** as per-document, session-only export history. It remembers only the last successfully completed export format and reruns the normal save flow; cancelled/failed exports do not replace history, and output destinations are not persisted or silently reused.
- Hardened Electron local-image preview/export reads against pathname replacement: image bytes are now read from a bound file descriptor only after its filesystem identity and containment are validated, with symlink and size checks retained.
- Added renderer-only file-library exclusion globs for Files, Articles and Open Quickly. Patterns are stored as preference text only, never as filesystem authority; the active/revealed document remains visible and Search in Folder continues to search the full authorized folder.

## 0.1.6 — 2026-10-10

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ This project is an independent clean-room implementation. It does not contain Ty
- Folder file tree, article list and document outline; the native runtime can create/rename/trash files and folders, refresh the tree, sort by name, modification time or creation time, optionally show hidden files, and reveal selected assets in Finder without promoting images into editable-document authority.
- Multi-document tabs with per-document dirty state, Save All, safe close prompts and next/previous tab navigation.
- Native recent documents, reopen-closed history, Open Quickly, Reveal in File Tree and Open File Location.
- Files, Articles and Open Quickly support optional relative exclusion globs for hiding generated or noisy entries without changing folder authority or Search in Folder.
- Explicit **Restore Previous Session** for clean saved tabs; missing, replaced or symlinked paths are skipped, and no folder grant is restored implicitly.
- Duplicate, Rename and Move To for saved local documents, including complete TextBundle package operations.
- Editable TextBundle packages (`.textbundle`) with payload-preserving saves and bundle-local assets.
Expand Down
4 changes: 2 additions & 2 deletions docs/PARITY_MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Reference inspected locally with REA: Typora 1.14.10 build 7786 on macOS. Primar
| Live rendered Markdown editing | shipped document/editor surfaces | Implemented | MDXEditor/Lexical, Markdown in/out, no proprietary editor code |
| Markdown/text files | plist document registrations | Implemented | Open/save/save-as and macOS file associations |
| TextBundle packages | plist editable TextBundle document role | Implemented | `.textbundle` is a macOS package association; payload-only saves preserve metadata/assets and Save As can create a new bundle |
| Open folder / file tree | static sidebar/file-library resources | Partial | Recursive Markdown/text tree and article list; native runtime adds create file/folder, rename, Move to Trash, refresh, name/modified/creation-date sorting and an opt-in host-enforced hidden-file visibility preference. Configurable glob filtering, pinning, grouping and other library controls remain |
| Open folder / file tree | static sidebar/file-library resources | Partial | Recursive Markdown/text tree and article list; native runtime adds create file/folder, rename, Move to Trash, refresh, name/modified/creation-date sorting and an opt-in host-enforced hidden-file visibility preference. Renderer-only relative exclusion globs can hide entries from Files/Articles/Open Quickly without changing folder authority or Search in Folder; pinning, grouping and other library controls remain |
| Recent/reopen/quick file workflows | static File menu resources | Implemented | Native recent documents, renderer closed-file history, and filename-only quick open over the current folder |
| Duplicate / Rename / Move To | static File menu resources | Implemented | Saved local documents only; tab paths update after filesystem operations and TextBundle packages move/copy as complete directories |
| Outline | static Outline resources | Implemented | H1-H6 extraction/navigation, flat or hierarchical presentation, configurable maximum heading level, hierarchical branch collapse/expand, Expand All / Collapse All and current-section highlighting/reveal are implemented. Hidden deeper active headings project to their nearest visible ancestor |
Expand Down Expand Up @@ -75,7 +75,7 @@ These are intentionally tracked even when they are not part of the current imple
| Next | Configurable outer-whitespace behavior | Explicit hard-break semantics are preserved and smart punctuation now delegates to the host's explicit native substitution controls. MDXEditor still trims outer document whitespace during root import/export even with `trim={false}`; preserving it would require an explicit conservative editor-state contract rather than an invisible TypeOpen-owned envelope |
| Later | Folder-session hints and stronger recovery/version workflows | Clean saved tabs now restore explicitly after native identity revalidation. Folder grants remain deliberately non-persistent; any future folder hint must still require a normal user-authorized Open Folder flow |
| Later | Multi-window / Open in New Window | Multi-tab model exists; the native app still centers on one main editing window |
| Later | Advanced file library: configurable glob patterns, pinned/recent locations and grouping | Native management, name/modified/creation-date sorting and a host-enforced hidden-file toggle exist |
| Later | Advanced file library: pinned/recent locations and grouping | Native management, name/modified/creation-date sorting, a host-enforced hidden-file toggle and renderer-only relative exclusion globs exist. Persisted folder locations remain deferred because they must not imply restored filesystem authority |
| Later | Advanced image management: configurable asset roots/copy rules, copy/move/rename/delete image assets, syntax switching, zoom/open actions | Safe local image insert/preview and adjacent `assets/` storage exist. Folder-tree image assets are kept out of the editable-document grant path; Electron preview/export reads bind validated image identity to the opened descriptor rather than reopening a pathname. Configurable write roots/copy rules remain deferred pending stronger destination-directory semantics; cloud/PicGo/PicList integration is intentionally deferred |
| Later | Export profiles: headers/footers, heading page breaks, export theme/CSS/templates, destination/post-export actions and richer Pandoc arguments | Core native/Pandoc export plus **Export with Previous** exist. Previous export history is per-document and session-only, stores only the last successful format, and always returns through the normal destination picker; overwrite/destination reuse remains intentionally deferred pending an explicit authority model |
| Later | Custom shortcuts, richer spellcheck language controls, separate light/dark custom themes and launch behavior preferences | Not required for core Markdown replacement but retained as parity backlog |
Expand Down
12 changes: 8 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { buildSessionIntent } from './lib/sessionRestore'
import { codeFenceSnippet, effectiveCodeBlockLanguage, normalizeCodeBlockLanguage, type CodeBlockLanguage } from './lib/codeLanguages'
import { normalizeOutlineMaxLevel, normalizeOutlineMode } from './lib/outlinePreferences'
import { isExportFormat, rememberSuccessfulExport, type ExportFormat } from './lib/exportHistory'
import { filterFileTreeByGlobs } from './lib/fileTreeFilters'

const EMPTY_DOCUMENT = ''
const PREF_KEY = 'typeopen.preferences.v1'
Expand All @@ -40,7 +41,8 @@ const DEFAULT_PREFS: TypeOpenPreferences = {
statusBar: true,
toolbar: false,
sidebarDefault: true,
showHiddenFiles: false
showHiddenFiles: false,
fileTreeExcludeGlobs: ''
}

function loadPageSetup(): PageSetupState {
Expand All @@ -62,7 +64,8 @@ function loadPreferences(): TypeOpenPreferences {
...parsed,
defaultCodeLanguage: normalizeCodeBlockLanguage(parsed?.defaultCodeLanguage),
outlineMode: normalizeOutlineMode(parsed?.outlineMode),
outlineMaxLevel: normalizeOutlineMaxLevel(parsed?.outlineMaxLevel)
outlineMaxLevel: normalizeOutlineMaxLevel(parsed?.outlineMaxLevel),
fileTreeExcludeGlobs: typeof parsed?.fileTreeExcludeGlobs === 'string' ? parsed.fileTreeExcludeGlobs.slice(0, 4096) : ''
}
} catch {
return DEFAULT_PREFS
Expand Down Expand Up @@ -187,6 +190,7 @@ export default function App() {
const filePath = activeDocument?.filePath ?? null
const dirty = activeDocument ? isDocumentDirty(activeDocument) : false
const hasDirtyDocuments = documents.some(isDocumentDirty)
const visibleFileTree = useMemo(() => filterFileTreeByGlobs(fileTree, folderPath, preferences.fileTreeExcludeGlobs, [filePath, fileReveal?.path]), [filePath, fileReveal?.path, fileTree, folderPath, preferences.fileTreeExcludeGlobs])
const outline = useMemo(() => extractOutline(markdown), [markdown])
const stats = useMemo(() => getDocumentStats(markdown), [markdown])
const searchResult = useMemo(() => searchText(markdown, findQuery, { matchCase, wholeWord, regex: regexSearch }), [findQuery, markdown, matchCase, regexSearch, wholeWord])
Expand Down Expand Up @@ -1146,7 +1150,7 @@ export default function App() {
<button className="document-tab-add" title="New Tab" aria-label="New Tab" onClick={newDocument}><Plus size={14} /></button>
</div>
<div className="workspace">
<Sidebar visible={sidebarVisible} tab={sidebarTab} onTabChange={setSidebarTab} outline={outline} outlineDocumentKey={activeDocumentId} activeOutlineSlug={activeOutlineSlug} outlineMode={preferences.outlineMode} outlineMaxLevel={preferences.outlineMaxLevel} fileTree={fileTree} currentFilePath={filePath} revealFilePath={fileReveal?.path ?? null} revealRequest={fileReveal?.request ?? 0} onOpenFile={(path) => void loadFile(path)} onRevealEntry={(node) => void revealFolderEntryInFinder(node)} onHeadingClick={scrollToHeading} onRequestFocusEditor={() => editorRef.current?.focus()} folderPath={folderPath} onCreateEntry={beginCreateFolderEntry} onRenameEntry={beginRenameFolderEntry} onTrashEntry={(node) => void trashFolderEntry(node)} onRefreshFolder={() => { if (folderPath) void refreshFolder(folderPath) }} />
<Sidebar visible={sidebarVisible} tab={sidebarTab} onTabChange={setSidebarTab} outline={outline} outlineDocumentKey={activeDocumentId} activeOutlineSlug={activeOutlineSlug} outlineMode={preferences.outlineMode} outlineMaxLevel={preferences.outlineMaxLevel} fileTree={visibleFileTree} currentFilePath={filePath} revealFilePath={fileReveal?.path ?? null} revealRequest={fileReveal?.request ?? 0} onOpenFile={(path) => void loadFile(path)} onRevealEntry={(node) => void revealFolderEntryInFinder(node)} onHeadingClick={scrollToHeading} onRequestFocusEditor={() => editorRef.current?.focus()} folderPath={folderPath} onCreateEntry={beginCreateFolderEntry} onRenameEntry={beginRenameFolderEntry} onTrashEntry={(node) => void trashFolderEntry(node)} onRefreshFolder={() => { if (folderPath) void refreshFolder(folderPath) }} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear selections hidden by an exclusion change

When a user selects a directory or image and then adds a glob that excludes it, visibleFileTree removes the row but Sidebar retains the node in its local selectedNode state. The toolbar therefore remains enabled with no visible selection, and Create, Rename, Reveal, or Move to Trash still operates on that hidden stale path; in particular, clicking the trash action can remove an item the user can no longer see. Clear the selection whenever its path is absent from the newly filtered tree.

Useful? React with 👍 / 👎.

<main className="document-workspace">
<FindBar
visible={findVisible}
Expand Down Expand Up @@ -1205,7 +1209,7 @@ export default function App() {
</main>
</div>
<GlobalSearch visible={globalSearchVisible} folderPath={folderPath} onClose={() => setGlobalSearchVisible(false)} onOpenResult={openGlobalSearchResult} />
<QuickOpen visible={quickOpenVisible} folderPath={folderPath} fileTree={fileTree} onClose={() => setQuickOpenVisible(false)} onOpenFile={loadFile} />
<QuickOpen visible={quickOpenVisible} folderPath={folderPath} fileTree={visibleFileTree} onClose={() => setQuickOpenVisible(false)} onOpenFile={loadFile} />
<AboutDialog
visible={aboutVisible}
version={__TYPEOPEN_VERSION__}
Expand Down
3 changes: 3 additions & 0 deletions src/components/PreferencesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface TypeOpenPreferences {
toolbar: boolean
sidebarDefault: boolean
showHiddenFiles: boolean
fileTreeExcludeGlobs: string
}

interface Props {
Expand Down Expand Up @@ -71,6 +72,8 @@ export function PreferencesDialog({ visible, preferences, onChange, onClose, onC
{tab === 'files' && <>
<label className="switch-row"><input type="checkbox" checked={preferences.autosave} onChange={(event) => patch({ autosave: event.target.checked })} /> Autosave open files after changes</label>
<label className="switch-row"><input type="checkbox" checked={preferences.showHiddenFiles} onChange={(event) => patch({ showHiddenFiles: event.target.checked })} /> Show hidden files in folder tree</label>
<label>Library exclusions<textarea maxLength={4096} rows={4} value={preferences.fileTreeExcludeGlobs} placeholder={'dist/**\n*.tmp\n.coverage'} onChange={(event) => patch({ fileTreeExcludeGlobs: event.target.value })} /></label>
<p className="preference-note">One relative glob per line. Supports <code>*</code>, <code>?</code> and <code>**</code>. This only hides entries from Files, Articles and Open Quickly; Search in Folder still searches the authorized folder.</p>
<p className="preference-note">Dropped and pasted image files are stored in an <code>assets/</code> directory next to the document when possible.</p>
</>}
{tab === 'markdown' && <>
Expand Down
5 changes: 5 additions & 0 deletions src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ export function Sidebar({ visible, tab, onTabChange, outline, outlineDocumentKey
setCollapsedOutline(new Set())
}, [outlineDocumentKey])

useEffect(() => {
if (!selectedNode || fileTree.some((node) => containsPath(node, selectedNode.path))) return
setSelectedNode(null)
}, [fileTree, selectedNode])

useEffect(() => {
if (outlineMode !== 'hierarchical' || !effectiveActiveOutlineSlug) return
const ancestors = outlineAncestorSlugs(outlineTree, effectiveActiveOutlineSlug)
Expand Down
96 changes: 96 additions & 0 deletions src/lib/fileTreeFilters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
function normalizeSeparators(value: string): string {
return value.replace(/\\/g, '/').replace(/\/+$/, '')
}

function normalizePattern(value: string): string {
return value.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '')
}

function escapeRegexCharacter(value: string): string {
return /[\\^$.*+?()[\]{}|]/.test(value) ? `\\${value}` : value
}

function globSource(pattern: string): string {
let source = ''
let index = 0
while (index < pattern.length) {
if (pattern.slice(index, index + 3) === '**/') {
source += '(?:.*/)?'
index += 3
continue
}
if (pattern.slice(index, index + 3) === '/**' && index + 3 === pattern.length) {
source += '(?:/.*)?'
index += 3
continue
}
if (pattern.slice(index, index + 2) === '**') {
source += '.*'
index += 2
continue
}
if (pattern[index] === '*') source += '[^/]*'
else if (pattern[index] === '?') source += '[^/]'
else source += escapeRegexCharacter(pattern[index])
index += 1
}
return source
}

interface CompiledPattern {
basenameOnly: boolean
expression: RegExp
}

export function compileFileTreeExcludeGlobs(value: string): CompiledPattern[] {
if (typeof value !== 'string' || !value.trim()) return []
return value
.split(/\r?\n/)
.slice(0, 100)
.map((line) => normalizePattern(line).slice(0, 256))
.filter(Boolean)
Comment on lines +48 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count only nonempty patterns toward the glob limit

When the preference contains blank separator lines, the 100-line limit is applied before normalization and empty-line removal, so those blanks consume pattern slots and valid globs later in the textarea are silently ignored. For example, 50 patterns separated by blank lines already exhaust the limit before any subsequent entries are compiled, causing entries the user explicitly excluded to remain visible. Filter empty normalized lines before taking the first 100 patterns.

Useful? React with 👍 / 👎.

.map((pattern) => ({
basenameOnly: !pattern.includes('/'),
expression: new RegExp(`^${globSource(pattern)}$`)
}))
Comment on lines +52 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compile single-character globs as Unicode-aware

For filenames containing a non-BMP character such as an emoji, ? does not behave as a single-character wildcard because the generated regex is compiled without the Unicode flag and [^/] consumes only one UTF-16 surrogate. Consequently, a documented pattern such as ?.md fails to exclude 😀.md even though it contains one user-visible character. Compile the expression in Unicode mode so the wildcard consumes one code point.

Useful? React with 👍 / 👎.

}

function relativeTreePath(nodePath: string, folderPath: string): string | null {
const node = normalizeSeparators(nodePath)
const root = normalizeSeparators(folderPath)
if (node === root) return ''
return node.startsWith(`${root}/`) ? node.slice(root.length + 1) : null
}

function isPreservedBranch(nodePath: string, preservePaths: string[]): boolean {
const node = normalizeSeparators(nodePath)
return preservePaths.some((candidate) => candidate === node || candidate.startsWith(`${node}/`))
}

export function filterFileTreeByGlobs(
nodes: TypeOpenTreeNode[],
folderPath: string | null,
patternText: string,
preservePaths: Array<string | null | undefined> = []
): TypeOpenTreeNode[] {
if (!folderPath) return nodes
const patterns = compileFileTreeExcludeGlobs(patternText)
if (!patterns.length) return nodes

const preserved = preservePaths.filter((value): value is string => typeof value === 'string' && value.length > 0).map(normalizeSeparators)
const visit = (node: TypeOpenTreeNode, ancestorExcluded = false): TypeOpenTreeNode | null => {
const relativePath = relativeTreePath(node.path, folderPath)
const matches = relativePath !== null && patterns.some(({ basenameOnly, expression }) => expression.test(basenameOnly ? node.name : relativePath))
const excluded = ancestorExcluded || matches
const preservedBranch = isPreservedBranch(node.path, preserved)
if (excluded && !preservedBranch) return null

if (!node.children) return node
const children = node.children
.map((child) => visit(child, excluded))
.filter((child): child is TypeOpenTreeNode => child !== null)
return { ...node, children }
}

return nodes.map((node) => visit(node)).filter((node): node is TypeOpenTreeNode => node !== null)
}
1 change: 1 addition & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ button { -webkit-app-region: no-drag; }
.preference-pane label:not(.switch-row) { display: grid; grid-template-columns: 150px minmax(120px,1fr) auto; gap: 10px; align-items: center; font-size: 13px; }
.preference-pane select { justify-self: start; min-width: 120px; }
.preference-pane input[type="range"] { width: 100%; }
.preference-pane textarea { width: 100%; min-height: 78px; resize: vertical; border: 1px solid var(--border); border-radius: 5px; background: var(--bg); color: inherit; padding: 7px 8px; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
.switch-row { display: flex; align-items: center; gap: 9px; font-size: 13px; }
.preference-note { color: var(--muted); font-size: 12px; line-height: 1.55; margin: 0; max-width: 430px; }
.custom-theme-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
Expand Down
Loading
Loading