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
10 changes: 10 additions & 0 deletions assets/resources/docs/user_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ Open **Tools** to turn spellcheck on or off. When spellcheck is enabled, right-c

The **Tools** menu also includes Light, Dark, Solarized, Arctic Dark, and Forest themes. Your selected theme is remembered between sessions.

## Session restoration

Session restoration is an opt-in setting under **Tools → Restore Session**. When enabled, SnapDock remembers the open workspace and the saved-file tabs that were open (their order and the active tab) and reopens them on the next start.

- Only **saved** files are restored. Unsaved and untitled tabs, their content, and undo/scroll position are not saved.
- Files are reloaded from disk on start; the document contents you see come from the saved file, not a snapshot.
- Missing, moved, or inaccessible files are skipped without blocking startup.

Disable **Tools → Restore Session** to stop restoring tabs; doing so clears the stored session. Choosing **Save → Close Project** also clears the stored session. Users remain responsible for saving changes normally.

## Updates

Select **Tools → Update** to check for updates when that option is supported by your installation. Microsoft Store and Snap Store packages are normally updated by their stores, and update behaviour can differ for other Linux packages and WSL.
Expand Down
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
<div class="dropdown-panel" id="toolsMenu">
<button id="updateBtn">Update</button>
<button id="spellcheckBtn" aria-pressed="true">Spellcheck: On</button>
<button id="restoreSessionBtn" aria-pressed="false">Restore Session: Off</button>
<div class="dropdown-separator"></div>
<div class="dropdown-section-label">Editor Font</div>
<div class="dropdown-subgroup">
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"scripts": {
"start": "electron .",
"md:test": "node test/markdown-render.mjs",
"test": "npm run md:test && node tests/find.test.js && node tests/link-navigation.test.js && node --test tests/editor-indent.test.mjs",
"test": "npm run md:test && node tests/find.test.js && node tests/link-navigation.test.js && node --test tests/editor-indent.test.mjs && node --test tests/session-restore.test.mjs",
"build:dev": "node scripts/build-dev.js",
"build:test": "node scripts/build-test.js",
"build:win": "node scripts/build-win.js",
Expand Down
134 changes: 134 additions & 0 deletions src/modules/file/session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// src/modules/file/session.js
//
// Session restoration (opt-in, issue #244).
//
// Persists only navigation metadata — the last open workspace, the ordered
// list of saved-file tab paths, and the active saved-file tab — so that the
// tabs can be reopened on the next launch. File *content* is deliberately
// NOT stored here; it is always reloaded from disk. This is scoped to a
// workspace, mirroring the recent-files pattern, so sessions from different
// folders do not leak into one another.

import { loadWorkspace } from "./workspace.js";

const ENABLED_KEY = "snapdock_session_enabled";
const SESSION_PREFIX = "snapdock_session_";

// ------------------------------------------------------------------
// Enabled preference (opt-in, off by default)
export function isSessionRestoreEnabled() {
return localStorage.getItem(ENABLED_KEY) === "true";
}

export function setSessionRestoreEnabled(enabled) {
localStorage.setItem(ENABLED_KEY, String(enabled));
}

// ------------------------------------------------------------------
// Session metadata key (workspace-scoped)
function keyForWorkspace(workspace) {
return workspace ? `${SESSION_PREFIX}${workspace}` : null;
}

function getSessionKey() {
return keyForWorkspace(loadWorkspace());
}

// ------------------------------------------------------------------
// Persist the session metadata from the current tab state.
// Only saved-file tabs (with a filePath) are recorded; untitled tabs are
// excluded by design.
export function saveSession({ tabs, activeFile }) {
if (!isSessionRestoreEnabled()) return;

const key = getSessionKey();
if (!key) return;

const openFiles = (tabs || [])
.map((t) => t.filePath)
.filter((p) => typeof p === "string" && p.length > 0);

const data = {
workspacePath: loadWorkspace() || null,
openFiles,
activeFile: typeof activeFile === "string" ? activeFile : openFiles[openFiles.length - 1] || null,
};

try {
localStorage.setItem(key, JSON.stringify(data));
} catch (_) {
// Ignore serialization/storage failures; session restore is best-effort.
}
}

// ------------------------------------------------------------------
// Load the stored session metadata. Pass an explicit workspace path to read
// a specific session (used at startup and to enforce workspace isolation);
// when omitted, the current persisted workspace is used. Returns null when
// disabled, no session exists, or the data is corrupt.
export function loadSession(workspacePath) {
if (!isSessionRestoreEnabled()) return null;

const key = keyForWorkspace(workspacePath || loadWorkspace());
if (!key) return null;

let data = null;
try {
data = JSON.parse(localStorage.getItem(key) || "null");
} catch (_) {
return null;
}

if (!data || !Array.isArray(data.openFiles)) return null;
return data;
}

// ------------------------------------------------------------------
// Clear the current workspace's session metadata. Also called when the
// feature is disabled so stored session data is not left behind.
export function clearSession() {
const key = getSessionKey();
if (key) {
try {
localStorage.removeItem(key);
} catch (_) {
// best-effort
}
}
}

// ------------------------------------------------------------------
// Reopen the saved-file tabs for a workspace, in order, and restore the
// previously active tab. Files are (re)loaded from disk — never from
// localStorage — and anything missing/inaccessible is skipped. This must be
// called after the workspace is loaded (see app.js / tree.js event).
export function initSessionRestore() {
document.addEventListener("snapdock:workspaceLoaded", async (e) => {
const workspace = e.detail.path;
if (!workspace) return;

const session = loadSession(workspace);
if (!session || !Array.isArray(session.openFiles)) return;

// Enforce workspace isolation: only restore files that live under the
// triggered workspace so a stale/foreign session cannot leak in.
const { handleFileOpen } = await import("./open.js");
const { switchToTabByPath } = await import("./tabs.js");
const sep = workspace.includes("\\") ? "\\" : "/";
const prefix = workspace.endsWith(sep) ? workspace : workspace + sep;

for (const filePath of session.openFiles) {
if (typeof filePath !== "string") continue;
if (!filePath.startsWith(prefix)) continue; // isolation guard
const name = filePath.split(/[\\/]/).pop();
// handleFileOpen loads from disk and returns early on missing files.
await handleFileOpen(filePath, name);
}

// Restore the previously active saved-file tab, if it was reopened.
if (session.activeFile) {
switchToTabByPath(session.activeFile);
}
});
}

19 changes: 19 additions & 0 deletions src/modules/file/tabs.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
// src/modules/file/tabs.js
import { saveCurrentFile } from "./operations.js";
import { updateMetrics } from "../ui/metrics.js";
import { saveSession } from "./session.js";

let tabs = [];
let activeTabId = null;

const getEditor = () => document.getElementById("markdownInputMain");
let tabBarScrollSetup = false;

// Persist the session (navigation metadata only) after tab mutations. Only a
// saved-file tab can be the session's active file. This is a no-op unless
// session restoration is enabled (see session.js).
function persistSession() {
const activeFile = activeTabId
? (tabs.find(t => t.id === activeTabId)?.filePath || null)
: null;
saveSession({ tabs, activeFile });
}

function isTabBarScrollable(tabBar) {
return tabBar.scrollWidth > tabBar.clientWidth + 1;
}
Expand Down Expand Up @@ -75,6 +86,9 @@ export function createTab({ filePath = null, content = "", title = "Untitled" }
};

tabs.push(tab);

// Only tabs that correspond to a saved file are part of a restorable session.
if (tab.filePath) persistSession();
return tab;
}

Expand Down Expand Up @@ -120,6 +134,8 @@ export function switchToTab(tabId) {
}

renderTabs();
// Switching the active tab changes which file is active in the session.
persistSession();
}

/**
Expand Down Expand Up @@ -150,6 +166,7 @@ export async function closeTab(tabId) {
}
} else {
renderTabs();
persistSession();
}
}

Expand All @@ -164,6 +181,8 @@ export function moveTab(fromIndex, toIndex) {
const [moved] = tabs.splice(fromIndex, 1);
tabs.splice(toIndex, 0, moved);
renderTabs();
// Reordering changes tab order in the session.
persistSession();
}

// --- Drag & Drop state ---
Expand Down
50 changes: 50 additions & 0 deletions src/modules/ui/dropdownMenus.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
import { applyTheme } from "./theme.js";
import { openHelpModal } from "./help.js";
import { setEditorFont } from "./editorFont.mjs";
import {
isSessionRestoreEnabled,
setSessionRestoreEnabled,
clearSession,
saveSession,
} from "../file/session.js";
import { tabs, getActiveTab } from "../file/tabs.js";

// ─── Public API ────────────────────────────────────────────────

Expand Down Expand Up @@ -64,6 +71,12 @@ export function initToolsDropdown() {
initSpellcheckButton(spellcheckBtn);
}

// ── Session restoration (opt-in) ──
const restoreSessionBtn = document.getElementById("restoreSessionBtn");
if (restoreSessionBtn) {
initSessionRestoreButton(restoreSessionBtn);
}

// ── Themes ──
document.querySelectorAll(".theme-option").forEach((btn) => {
btn.addEventListener("click", () => {
Expand Down Expand Up @@ -143,6 +156,43 @@ function initSpellcheckButton(btn) {
});
}

// Session restoration toggle (opt-in, issue #244).
//
// Turning the feature on/off is persisted. Turning it OFF clears any stored
// session metadata (per acceptance criteria), then re-records the current
// navigation state so that re-enabling is not fed stale tab data.
function initSessionRestoreButton(btn) {
const applyState = (enabled) => {
btn.dataset.enabled = String(enabled);
btn.textContent = enabled ? "Restore Session: On" : "Restore Session: Off";
btn.classList.toggle("active", enabled);
btn.setAttribute("aria-pressed", String(enabled));
};

applyState(isSessionRestoreEnabled());

btn.addEventListener("click", () => {
const enabled = !isSessionRestoreEnabled();
setSessionRestoreEnabled(enabled);

if (enabled) {
// Persist the current navigation state immediately so the session
// reflects what is open right now. Guard on tabs.length because
// getActiveTab() creates an untitled tab when none exist.
const active = tabs.length ? getActiveTab() : null;
saveSession({
tabs,
activeFile: active && active.filePath ? active.filePath : null,
});
} else {
// Disabling clears stored session metadata.
clearSession();
}

applyState(enabled);
});
}

// Updater state machine.
//
// All update UI is driven from a single `state` value below, so the Tools
Expand Down
6 changes: 6 additions & 0 deletions src/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { initDropdownToggles, initToolsDropdown } from "./modules/ui/dropdownMen
import { initMetrics } from "./modules/ui/metrics.js";
import { initEditorFont } from "./modules/ui/editorFont.mjs";
import { clearWorkspace } from "./modules/file/workspace.js";
import { initSessionRestore, clearSession } from "./modules/file/session.js";
import { initEditorIndent } from "./modules/ui/editorIndent.js";

// Helper
Expand Down Expand Up @@ -42,12 +43,17 @@ window.workspaceAPI.onSaveAllForCloseRequest(async() => {

window.workspaceAPI.onClearForCloseRequest(() => {
clearWorkspace();
clearSession();
window.workspaceAPI.sendClearForCloseResult();
});

// --- MAIN BOOTSTRAP ---
window.addEventListener("DOMContentLoaded", () => {

// Register the session restore listener BEFORE initApp() so it can catch
// the startup snapdock:workspaceLoaded event dispatched during initApp.
initSessionRestore();

// Core App Initialization
initApp();

Expand Down
Loading