Snippet

Persist tab sessions without storing document text

A complete Swift session store that remembers tab metadata and selected tab while keeping document contents out of the session file.

swiftOct 6, 2026MIT

AI-generated, human-reviewed: the first draft was generated by an AI agent; I fact-checked, tested, and edited it. AI use policy

What it does#

Persists tab metadata such as title, file path, dirty state, selection, and recovery record ID, but intentionally excludes document text. On decode, it also ignores legacy text values if an older session file contains them. Privacy by omission: not glamorous, oddly effective.

You need#

  • macOS 15 or later
  • Xcode 27 or Swift 6.4
  • Foundation only
  • A separate recovery-copy flow for unsaved text, such as the recovery-copy service

Where it goes#

Save it as PrivateSessionStore.swift in your core layer. Call save when tabs change and load during app startup.

PrivateSessionStore.swift
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Sarang Datye
import Foundation

struct SessionTab: Identifiable, Codable, Equatable, Sendable {
    var id: UUID
    var title: String
    var filePath: String?
    var isDirty: Bool
    var selectionLocation: Int
    var selectionLength: Int
    var recoveryRecordID: UUID?
    var transientText: String

    private enum CodingKeys: String, CodingKey {
        case id
        case title
        case filePath
        case isDirty
        case selectionLocation
        case selectionLength
        case recoveryRecordID
    }

    init(
        id: UUID = UUID(),
        title: String = "Untitled",
        filePath: String? = nil,
        isDirty: Bool = false,
        selectionLocation: Int = 0,
        selectionLength: Int = 0,
        recoveryRecordID: UUID? = nil,
        transientText: String = ""
    ) {
        self.id = id
        self.title = title
        self.filePath = filePath
        self.isDirty = isDirty
        self.selectionLocation = max(0, selectionLocation)
        self.selectionLength = max(0, selectionLength)
        self.recoveryRecordID = recoveryRecordID
        self.transientText = transientText
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
        self.title = try container.decodeIfPresent(String.self, forKey: .title) ?? "Untitled"
        self.filePath = try container.decodeIfPresent(String.self, forKey: .filePath)
        self.isDirty = try container.decodeIfPresent(Bool.self, forKey: .isDirty) ?? false
        self.selectionLocation = max(0, try container.decodeIfPresent(Int.self, forKey: .selectionLocation) ?? 0)
        self.selectionLength = max(0, try container.decodeIfPresent(Int.self, forKey: .selectionLength) ?? 0)
        self.recoveryRecordID = try container.decodeIfPresent(UUID.self, forKey: .recoveryRecordID)
        self.transientText = ""
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(id, forKey: .id)
        try container.encode(title, forKey: .title)
        try container.encodeIfPresent(filePath, forKey: .filePath)
        try container.encode(isDirty, forKey: .isDirty)
        try container.encode(selectionLocation, forKey: .selectionLocation)
        try container.encode(selectionLength, forKey: .selectionLength)
        try container.encodeIfPresent(recoveryRecordID, forKey: .recoveryRecordID)
    }
}

struct SessionState: Codable, Equatable, Sendable {
    var schemaVersion: Int
    var tabs: [SessionTab]
    var selectedTabID: UUID?

    init(schemaVersion: Int = 1, tabs: [SessionTab], selectedTabID: UUID?) {
        self.schemaVersion = schemaVersion
        self.tabs = tabs
        self.selectedTabID = selectedTabID
    }
}

struct PrivateSessionStore {
    var fileManager: FileManager
    var encoder: JSONEncoder
    var decoder: JSONDecoder

    init(fileManager: FileManager = .default) {
        self.fileManager = fileManager
        self.encoder = JSONEncoder()
        self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        self.decoder = JSONDecoder()
    }

    func save(_ state: SessionState, to url: URL) throws {
        try fileManager.createDirectory(
            at: url.deletingLastPathComponent(),
            withIntermediateDirectories: true
        )
        try encoder.encode(state).write(to: url, options: [.atomic])
    }

    func load(from url: URL) -> SessionState? {
        guard let data = try? Data(contentsOf: url) else { return nil }
        return try? decoder.decode(SessionState.self, from: data)
    }

    func reconciled(_ state: SessionState) -> SessionState {
        let tabs = state.tabs.isEmpty ? [SessionTab()] : state.tabs.map { tab in
            var copy = tab
            copy.transientText = ""
            if tab.filePath == nil && tab.isDirty && tab.recoveryRecordID == nil {
                copy.isDirty = false
            }
            return copy
        }
        let selectedTabID = if let savedID = state.selectedTabID, tabs.contains(where: { $0.id == savedID }) {
            savedID
        } else {
            tabs.first?.id
        }
        return SessionState(
            schemaVersion: state.schemaVersion,
            tabs: tabs,
            selectedTabID: selectedTabID
        )
    }
}

Check that it works#

Encode a SessionState whose tab has transientText set. The resulting JSON should not contain the text or a transientText property. Decode a legacy session and the text comes back empty. Reconcile an empty tab list with a stale selected ID and verify the selected ID points at the newly created tab.