Added .sv_* files that are files for directories. So you can now edit

directories as files.
This commit is contained in:
sarah committed 2026-09-21 17:19:16 +02:00
1 parent 70a390f7cf
commit 6083e00504
9 files changed
+176 -18

No files matched your search

-3
View File
@@ -10,6 +10,3 @@ Frontend/dist/
.DS_Store .DS_Store
.vscode/ .vscode/
.idea/ .idea/
# Misc
.Todo/
+76 -3
View File
@@ -38,6 +38,64 @@ def ClassifyFile(EntryPath: Path) -> str:
return FileTypeMap.get(EntryPath.suffix.lower(), "unknown") return FileTypeMap.get(EntryPath.suffix.lower(), "unknown")
NoteFilePrefix = ".sv_"
DefaultNoteExtension = ".md"
def IsNoteFile(EntryName: str) -> bool:
"""A directory's own "file" - editable/viewable like any other file, but
hidden from normal listings behind the ShowSvFiles setting.
"""
return EntryName.startswith(NoteFilePrefix)
def FindDirectoryNote(DirectoryPath: Path) -> Path | None:
"""Find a directory's note file, whatever extension it currently has."""
Prefix = f"{NoteFilePrefix}{DirectoryPath.name}."
for Candidate in DirectoryPath.iterdir():
if Candidate.is_file() and Candidate.name.startswith(Prefix):
return Candidate
return None
def EnsureDirectoryNote(DirectoryPath: Path) -> Path:
"""Find a directory's note file, creating one (defaulting to markdown)
if it doesn't have one yet - e.g. a directory that predates this feature.
"""
Existing = FindDirectoryNote(DirectoryPath)
if Existing is not None:
return Existing
NotePath = DirectoryPath / f"{NoteFilePrefix}{DirectoryPath.name}{DefaultNoteExtension}"
NotePath.touch()
return NotePath
def GetDirectoryNoteInfo(DirectoryPath: Path) -> dict[str, Any] | None:
"""Read-only lookup for listings - never creates a note."""
NotePath = FindDirectoryNote(DirectoryPath)
if NotePath is None:
return None
return {"Name": NotePath.name, "FileType": ClassifyFile(NotePath)}
def RenameDirectoryNote(DirectoryPath: Path, OldDirectoryName: str) -> None:
"""After a directory is renamed/moved, keep its note file's name (which
embeds the directory's own name) in sync, preserving its extension.
"""
OldPrefix = f"{NoteFilePrefix}{OldDirectoryName}."
for Candidate in DirectoryPath.iterdir():
if Candidate.is_file() and Candidate.name.startswith(OldPrefix):
NewName = f"{NoteFilePrefix}{DirectoryPath.name}{Candidate.suffix}"
Candidate.rename(DirectoryPath / NewName)
return
def ListRoots() -> list[dict[str, Any]]: def ListRoots() -> list[dict[str, Any]]:
"""Return the configured roots as a flat list for the frontend.""" """Return the configured roots as a flat list for the frontend."""
return [{"Name": Name} for Name in GetRoots()] return [{"Name": Name} for Name in GetRoots()]
@@ -52,6 +110,7 @@ def CreateEntry(RootName: str, RelativePath: str, IsDirectory: bool) -> None:
if IsDirectory: if IsDirectory:
EntryPath.mkdir(parents=False) EntryPath.mkdir(parents=False)
EnsureDirectoryNote(EntryPath)
else: else:
EntryPath.parent.mkdir(parents=True, exist_ok=True) EntryPath.parent.mkdir(parents=True, exist_ok=True)
EntryPath.touch() EntryPath.touch()
@@ -70,6 +129,7 @@ def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: s
if DestinationPath.exists(): if DestinationPath.exists():
raise FileExistsError(f"{DestinationPath.name} already exists.") raise FileExistsError(f"{DestinationPath.name} already exists.")
OldName = SourcePath.name
DestinationPath.parent.mkdir(parents=True, exist_ok=True) DestinationPath.parent.mkdir(parents=True, exist_ok=True)
try: try:
@@ -78,6 +138,9 @@ def MoveEntry(RootName: str, SourceRelativePath: str, DestinationRelativePath: s
# Cross-device or other rename failures — fall back to a copy+delete. # Cross-device or other rename failures — fall back to a copy+delete.
shutil.move(str(SourcePath), str(DestinationPath)) shutil.move(str(SourcePath), str(DestinationPath))
if DestinationPath.is_dir():
RenameDirectoryNote(DestinationPath, OldName)
def DeleteEntry(RootName: str, RelativePath: str) -> None: def DeleteEntry(RootName: str, RelativePath: str) -> None:
"""Delete a file, or an entire directory tree.""" """Delete a file, or an entire directory tree."""
@@ -92,11 +155,17 @@ def DeleteEntry(RootName: str, RelativePath: str) -> None:
EntryPath.unlink() EntryPath.unlink()
def ListDirectory(RootName: str, RelativePath: str, ShowHidden: bool = False) -> list[dict[str, Any]]: def ListDirectory(
RootName: str, RelativePath: str, ShowHidden: bool = False, ShowSvFiles: bool = False
) -> list[dict[str, Any]]:
"""List the immediate contents of a directory. """List the immediate contents of a directory.
SaraTag's internal files are always hidden, regardless of ShowHidden. SaraTag's internal files are always hidden, regardless of ShowHidden.
Dotfiles/dotfolders are hidden unless ShowHidden is True. Note files (.sv_*) are hidden unless ShowSvFiles is True - independently
of ShowHidden, since they're an implementation detail rather than a
normal dotfile. Every directory's Note metadata is still attached
regardless of ShowSvFiles, so clicking the directory can open it.
Other dotfiles/dotfolders are hidden unless ShowHidden is True.
""" """
DirectoryPath = ResolvePath(RootName, RelativePath) DirectoryPath = ResolvePath(RootName, RelativePath)
@@ -114,7 +183,10 @@ def ListDirectory(RootName: str, RelativePath: str, ShowHidden: bool = False) ->
if IsInternalPath(EntryPath): if IsInternalPath(EntryPath):
continue continue
if not ShowHidden and EntryPath.name.startswith("."): if IsNoteFile(EntryPath.name):
if not ShowSvFiles:
continue
elif not ShowHidden and EntryPath.name.startswith("."):
continue continue
IsDirectory = EntryPath.is_dir() IsDirectory = EntryPath.is_dir()
@@ -124,6 +196,7 @@ def ListDirectory(RootName: str, RelativePath: str, ShowHidden: bool = False) ->
"IsDirectory": IsDirectory, "IsDirectory": IsDirectory,
"FileType": None if IsDirectory else ClassifyFile(EntryPath), "FileType": None if IsDirectory else ClassifyFile(EntryPath),
"Size": None if IsDirectory else EntryPath.stat().st_size, "Size": None if IsDirectory else EntryPath.stat().st_size,
"Note": GetDirectoryNoteInfo(EntryPath) if IsDirectory else None,
} }
) )
+32 -3
View File
@@ -18,7 +18,15 @@ from BookReader import ReadBookForFile
from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave
from Core import ResolvePath from Core import ResolvePath
from Editor import WriteTextFile from Editor import WriteTextFile
from Explorer import ClassifyFile, CreateEntry, DeleteEntry, ListDirectory, ListRoots, MoveEntry from Explorer import (
ClassifyFile,
CreateEntry,
DeleteEntry,
EnsureDirectoryNote,
ListDirectory,
ListRoots,
MoveEntry,
)
from Groups import ( from Groups import (
CreateGroup, CreateGroup,
DeleteGroup, DeleteGroup,
@@ -378,8 +386,10 @@ def GetTreeEndpoint(
RequirePathAccessible(Root, Path, CurrentUser) RequirePathAccessible(Root, Path, CurrentUser)
try: try:
ShowHidden = LoadSettings(CurrentUser["Username"]).get("ShowHiddenFolders", False) UserSettings = LoadSettings(CurrentUser["Username"])
Entries = ListDirectory(Root, Path, ShowHidden) ShowHidden = UserSettings.get("ShowHiddenFolders", False)
ShowSvFiles = UserSettings.get("ShowSvFiles", False)
Entries = ListDirectory(Root, Path, ShowHidden, ShowSvFiles)
except (ValueError, NotADirectoryError, FileNotFoundError) as Error: except (ValueError, NotADirectoryError, FileNotFoundError) as Error:
raise HTTPException(status_code=400, detail=str(Error)) raise HTTPException(status_code=400, detail=str(Error))
@@ -400,6 +410,25 @@ def GetTreeEndpoint(
] ]
@Application.post("/api/note")
def PostNoteEndpoint(Root: str, Path: str, CurrentUser: dict = Depends(GetCurrentUser)) -> dict:
"""Find (or create, for a directory that predates this feature) the
directory's own note file, so it can be opened like any other file.
"""
RequirePathAccessible(Root, Path, CurrentUser)
try:
DirectoryPath = ResolvePath(Root, Path)
except ValueError as Error:
raise HTTPException(status_code=400, detail=str(Error))
if not DirectoryPath.is_dir():
raise HTTPException(status_code=400, detail="Not a directory.")
NotePath = EnsureDirectoryNote(DirectoryPath)
return {"Name": NotePath.name, "FileType": ClassifyFile(NotePath)}
class CreateEntryRequest(BaseModel): class CreateEntryRequest(BaseModel):
IsDirectory: bool IsDirectory: bool
+1
View File
@@ -34,6 +34,7 @@ DefaultSettings: dict[str, Any] = {
"FontSize": 14, "FontSize": 14,
"ThemeName": "CatppuccinMocha", "ThemeName": "CatppuccinMocha",
"ShowHiddenFolders": False, "ShowHiddenFolders": False,
"ShowSvFiles": False,
"ShowBreadcrumbs": True, "ShowBreadcrumbs": True,
"UseHyphenBullets": True, "UseHyphenBullets": True,
"UseAsciiFolderIcons": True, "UseAsciiFolderIcons": True,
+5
View File
@@ -194,6 +194,11 @@ export function DeleteEntry(RootName, RelativePath) {
return FetchJson(`/tree?${Params}`, { method: "DELETE" }); return FetchJson(`/tree?${Params}`, { method: "DELETE" });
} }
export function EnsureDirectoryNote(RootName, RelativePath) {
const Params = new URLSearchParams({ Root: RootName, Path: RelativePath });
return FetchJson(`/note?${Params}`, { method: "POST" });
}
// --- Settings / themes ------------------------------------------------- // --- Settings / themes -------------------------------------------------
export function FetchSettings() { export function FetchSettings() {
+49 -7
View File
@@ -7,7 +7,14 @@ import DirectoryPane from "./Components/DirectoryPane.jsx";
import TagPanel from "./Components/TagPanel.jsx"; import TagPanel from "./Components/TagPanel.jsx";
import SettingsPanel from "./Components/SettingsPanel.jsx"; import SettingsPanel from "./Components/SettingsPanel.jsx";
import LoginScreen from "./Components/LoginScreen.jsx"; import LoginScreen from "./Components/LoginScreen.jsx";
import { FetchAuthStatus, FetchMe, FetchRoots, FetchSettings, FetchTheme } from "./Api.js"; import {
EnsureDirectoryNote,
FetchAuthStatus,
FetchMe,
FetchRoots,
FetchSettings,
FetchTheme,
} from "./Api.js";
import { PaletteRoles } from "./ThemeRoles.js"; import { PaletteRoles } from "./ThemeRoles.js";
function ApplyTheme(Palette) { function ApplyTheme(Palette) {
@@ -41,6 +48,7 @@ export default function App() {
const [ViewPath, SetViewPath] = useState(""); const [ViewPath, SetViewPath] = useState("");
const [ViewIsDirectory, SetViewIsDirectory] = useState(true); const [ViewIsDirectory, SetViewIsDirectory] = useState(true);
const [ViewFileType, SetViewFileType] = useState(null); const [ViewFileType, SetViewFileType] = useState(null);
const [ViewNoteFilePath, SetViewNoteFilePath] = useState(null);
const [CurrentDirectoryPath, SetCurrentDirectoryPath] = useState(""); const [CurrentDirectoryPath, SetCurrentDirectoryPath] = useState("");
const [SidebarKey, SetSidebarKey] = useState(0); const [SidebarKey, SetSidebarKey] = useState(0);
const [Settings, SetSettings] = useState(null); const [Settings, SetSettings] = useState(null);
@@ -104,6 +112,7 @@ export default function App() {
SetViewPath(FilePath); SetViewPath(FilePath);
SetViewIsDirectory(false); SetViewIsDirectory(false);
SetViewFileType(FileType); SetViewFileType(FileType);
SetViewNoteFilePath(null);
SetCurrentDirectoryPath(GetParentPath(FilePath)); SetCurrentDirectoryPath(GetParentPath(FilePath));
} }
@@ -111,6 +120,7 @@ export default function App() {
SetViewPath(DirectoryPath); SetViewPath(DirectoryPath);
SetViewIsDirectory(true); SetViewIsDirectory(true);
SetViewFileType(null); SetViewFileType(null);
SetViewNoteFilePath(null);
SetCurrentDirectoryPath(DirectoryPath); SetCurrentDirectoryPath(DirectoryPath);
} }
@@ -118,9 +128,34 @@ export default function App() {
SetCurrentDirectoryPath(DirectoryPath); SetCurrentDirectoryPath(DirectoryPath);
} }
function HandleEntrySelect(EntryPath, IsDirectory, FileType) { // A directory is opened the same way a file is - via its own note file -
// rather than by drilling into its listing. Browsing a directory's
// contents happens separately, through the sidebar tree.
function HandleSelectDirectoryNote(DirectoryPath, NoteName, NoteFileType) {
SetViewPath(DirectoryPath);
SetViewIsDirectory(false);
SetViewFileType(NoteFileType);
SetViewNoteFilePath(`${DirectoryPath}/${NoteName}`);
SetCurrentDirectoryPath(DirectoryPath);
}
async function OpenDirectoryNote(DirectoryPath, Note) {
if (Note) {
HandleSelectDirectoryNote(DirectoryPath, Note.Name, Note.FileType);
return;
}
try {
const Created = await EnsureDirectoryNote(ActiveRoot, DirectoryPath);
HandleSelectDirectoryNote(DirectoryPath, Created.Name, Created.FileType);
} catch (Error) {
window.alert(Error.message);
}
}
function HandleEntrySelect(EntryPath, IsDirectory, FileType, Note) {
if (IsDirectory) { if (IsDirectory) {
HandleNavigateDirectory(EntryPath); OpenDirectoryNote(EntryPath, Note);
} else { } else {
HandleSelectFile(EntryPath, FileType); HandleSelectFile(EntryPath, FileType);
} }
@@ -133,7 +168,11 @@ export default function App() {
function HandleEntryRenamed(OldPath, NewPath, IsDirectory) { function HandleEntryRenamed(OldPath, NewPath, IsDirectory) {
RefreshSidebar(); RefreshSidebar();
if (!ViewIsDirectory && ViewPath === OldPath) { if (ViewNoteFilePath && ViewPath === OldPath) {
// Viewing this directory's own note - the server already renamed the
// note file to match, so just re-resolve it under the new path.
OpenDirectoryNote(NewPath, null);
} else if (!ViewIsDirectory && ViewPath === OldPath) {
HandleSelectFile(NewPath, ViewFileType); HandleSelectFile(NewPath, ViewFileType);
} else if (ViewIsDirectory && ViewPath === OldPath) { } else if (ViewIsDirectory && ViewPath === OldPath) {
HandleNavigateDirectory(NewPath); HandleNavigateDirectory(NewPath);
@@ -178,6 +217,7 @@ export default function App() {
CurrentDirectoryPath={CurrentDirectoryPath} CurrentDirectoryPath={CurrentDirectoryPath}
CurrentUser={CurrentUser} CurrentUser={CurrentUser}
OnSelectFile={HandleSelectFile} OnSelectFile={HandleSelectFile}
OnSelectDirectory={OpenDirectoryNote}
OnNavigateDirectory={HandleNavigateDirectory} OnNavigateDirectory={HandleNavigateDirectory}
OnBrowseDirectory={HandleBrowseDirectory} OnBrowseDirectory={HandleBrowseDirectory}
OnEntryRenamed={HandleEntryRenamed} OnEntryRenamed={HandleEntryRenamed}
@@ -197,7 +237,7 @@ export default function App() {
<Breadcrumb <Breadcrumb
RootName={ActiveRoot} RootName={ActiveRoot}
FilePath={ViewPath} FilePath={ViewPath}
IsDirectory={ViewIsDirectory} IsDirectory={ViewIsDirectory || Boolean(ViewNoteFilePath)}
OnNavigate={HandleNavigateDirectory} OnNavigate={HandleNavigateDirectory}
/> />
) : ( ) : (
@@ -234,7 +274,7 @@ export default function App() {
) : ( ) : (
<PreviewPane <PreviewPane
RootName={ActiveRoot} RootName={ActiveRoot}
FilePath={ViewPath} FilePath={ViewNoteFilePath || ViewPath}
FileType={ViewFileType} FileType={ViewFileType}
OnNavigateLink={HandleSelectFile} OnNavigateLink={HandleSelectFile}
ShowGitBlame={Settings ? Settings.ShowGitBlame : false} ShowGitBlame={Settings ? Settings.ShowGitBlame : false}
@@ -242,7 +282,9 @@ export default function App() {
StickyToolbar={Settings ? Settings.StickyToolbar : true} StickyToolbar={Settings ? Settings.StickyToolbar : true}
/> />
)} )}
{!ViewIsDirectory && <TagPanel RootName={ActiveRoot} FilePath={ViewPath} />} {!ViewIsDirectory && !ViewNoteFilePath && (
<TagPanel RootName={ActiveRoot} FilePath={ViewPath} />
)}
</div> </div>
</div> </div>
{IsSettingsOpen && ( {IsSettingsOpen && (
+3 -1
View File
@@ -149,7 +149,9 @@ export default function DirectoryPane({
<div <div
className="DirectoryEntry" className="DirectoryEntry"
key={Entry.Name} key={Entry.Name}
onClick={() => OnEntrySelect(JoinPath(Entry.Name), Entry.IsDirectory, Entry.FileType)} onClick={() =>
OnEntrySelect(JoinPath(Entry.Name), Entry.IsDirectory, Entry.FileType, Entry.Note)
}
> >
<span className="TreeIcon"> <span className="TreeIcon">
{Entry.IsDirectory {Entry.IsDirectory
@@ -323,6 +323,11 @@ export default function SettingsPanel({
<Switch Checked={Settings.ShowHiddenFolders} OnChange={(Event) => UpdateField("ShowHiddenFolders", Event.target.checked)} /> <Switch Checked={Settings.ShowHiddenFolders} OnChange={(Event) => UpdateField("ShowHiddenFolders", Event.target.checked)} />
</div> </div>
<div className="SettingsRowInline">
<label className="SettingsLabel">Show directory note files (.sv_*)</label>
<Switch Checked={Settings.ShowSvFiles} OnChange={(Event) => UpdateField("ShowSvFiles", Event.target.checked)} />
</div>
<div className="SettingsRowInline"> <div className="SettingsRowInline">
<label className="SettingsLabel">Show breadcrumbs</label> <label className="SettingsLabel">Show breadcrumbs</label>
<Switch Checked={Settings.ShowBreadcrumbs} OnChange={(Event) => UpdateField("ShowBreadcrumbs", Event.target.checked)} /> <Switch Checked={Settings.ShowBreadcrumbs} OnChange={(Event) => UpdateField("ShowBreadcrumbs", Event.target.checked)} />
+5 -1
View File
@@ -20,6 +20,7 @@ function TreeNode({
SelectedFile, SelectedFile,
CurrentDirectoryPath, CurrentDirectoryPath,
OnSelectFile, OnSelectFile,
OnSelectDirectory,
OnNavigateDirectory, OnNavigateDirectory,
OnBrowseDirectory, OnBrowseDirectory,
OnEntryRenamed, OnEntryRenamed,
@@ -65,7 +66,7 @@ function TreeNode({
OnBrowseDirectory(FullPath); OnBrowseDirectory(FullPath);
if (NavigateIntoDirectories) { if (NavigateIntoDirectories) {
OnNavigateDirectory(FullPath); OnSelectDirectory(FullPath, Entry.Note);
} }
} }
@@ -156,6 +157,7 @@ function TreeNode({
SelectedFile={SelectedFile} SelectedFile={SelectedFile}
CurrentDirectoryPath={CurrentDirectoryPath} CurrentDirectoryPath={CurrentDirectoryPath}
OnSelectFile={OnSelectFile} OnSelectFile={OnSelectFile}
OnSelectDirectory={OnSelectDirectory}
OnNavigateDirectory={OnNavigateDirectory} OnNavigateDirectory={OnNavigateDirectory}
OnBrowseDirectory={OnBrowseDirectory} OnBrowseDirectory={OnBrowseDirectory}
OnEntryRenamed={OnEntryRenamed} OnEntryRenamed={OnEntryRenamed}
@@ -213,6 +215,7 @@ export default function Sidebar({
CurrentDirectoryPath, CurrentDirectoryPath,
CurrentUser, CurrentUser,
OnSelectFile, OnSelectFile,
OnSelectDirectory,
OnNavigateDirectory, OnNavigateDirectory,
OnBrowseDirectory, OnBrowseDirectory,
OnEntryRenamed, OnEntryRenamed,
@@ -319,6 +322,7 @@ export default function Sidebar({
SelectedFile={SelectedFile} SelectedFile={SelectedFile}
CurrentDirectoryPath={CurrentDirectoryPath} CurrentDirectoryPath={CurrentDirectoryPath}
OnSelectFile={OnSelectFile} OnSelectFile={OnSelectFile}
OnSelectDirectory={OnSelectDirectory}
OnNavigateDirectory={OnNavigateDirectory} OnNavigateDirectory={OnNavigateDirectory}
OnBrowseDirectory={OnBrowseDirectory} OnBrowseDirectory={OnBrowseDirectory}
OnEntryRenamed={OnEntryRenamed} OnEntryRenamed={OnEntryRenamed}