oficina / docs Public

Open editable local drafts immediately and rename document titles

Paul Campbell pushed by paul@keymail.dev 5c177f97925b57ba42b67a338791742d2b677c14 parent dde2aaf
45 files changed, +663 −55
  • internal/docs/app.go +1 −0
  • internal/docs/handlers.go +54 −1
  • internal/docs/migrations/0008_document_creation_token.sql +2 −0
  • internal/docs/models.go +2 −1
  • internal/docs/native_test.go +51 −0
  • internal/docs/store.go +37 −0
  • native/INSTANT-DOCUMENTS.md +28 −0
  • native/apple/Sources/DocsApp/DocsAccountWindow.swift +4 −0
  • native/apple/Sources/DocsApp/DocsDocumentWindow.swift +1 −1
  • native/apple/Sources/DocsApp/DocsWindowCommands.swift +7 −2
  • native/apple/Sources/DocsApp/DocsWindows.swift +17 −2
  • native/apple/Sources/DocsKit/DocsDocumentMenu.swift +4 −2
  • native/apple/Sources/DocsKit/DocsEditorContext.swift +1 −0
  • native/apple/Sources/DocsKit/DocsModel.swift +108 −19
  • native/apple/Sources/DocsKit/DocsWorkspace.swift +62 −22
  • native/apple/Sources/DocsKit/Resources/ar.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/bn.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/en.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/es.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/ga.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/hi.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/ja.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/pt.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/ru.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/vi.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/yue.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Localizable.strings +2 −0
  • native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings +2 −0
  • native/apple/Tests/DocsKitTests/DocumentDraftTests.swift +72 −0
  • native/apple/Tests/DocsWindowTests/WindowRoutingTests.swift +17 −0
  • native/scripts/test-document-drafts.py +77 −0
  • native/scripts/test-document-window.py +10 −3
  • native/smoke/document-window/README.md +10 −0
  • native/smoke/document-window/Sources/Smoke.swift +50 −2

CI passed — run details

diff --git a/internal/docs/app.go b/internal/docs/app.go
index ccfb4e0..2422f7f 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -593,6 +593,7 @@ func New(cfg Config) (*App, error) {
// check of its own — a person's document titles are theirs.
r.Get("/documents/search", h.searchJSON)
r.Get("/d/{sqid}", h.show)
+ r.Post("/d/{sqid}/title", h.rename)
// Task 5: the update log, what the browser's editor reads to
// build its Yjs doc (GET) and posts new edits back through
// (POST) — see handlers.go's updatesGet/updatesPost. Neither
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index a0b6c73..3bfc1f8 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -11,6 +11,7 @@ import (
"strings"
"time"
+ "github.com/google/uuid"
"gorm.io/gorm"
"amadan.net/oficina/estilo.git/savestatus"
@@ -347,12 +348,30 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
return
}
+ r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadRequest)
+ return
+ }
+ token := r.PostFormValue("creation_token")
+ if token != "" {
+ parsed, err := uuid.Parse(token)
+ if err != nil {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadRequest)
+ return
+ }
+ token = parsed.String()
+ }
title := strings.TrimSpace(r.PostFormValue("title"))
+ if len(title) > 1024 {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadRequest)
+ return
+ }
if title == "" {
title = rastrillo.T(r, "docs.document.untitled")
}
- doc, err := h.store.Create(r.Context(), owner, title)
+ doc, err := h.store.CreateDraft(r.Context(), owner, title, token)
if err != nil {
h.logger.Error("docs: create document", "err", err)
http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
@@ -1002,3 +1021,37 @@ func formatWhen(now, t time.Time) string {
}
return t.Format("Jan 2, 2006")
}
+
+func (h *handlers) rename(w http.ResponseWriter, r *http.Request) {
+ doc, ok := h.getDocumentForUpdates(w, r)
+ if !ok {
+ return
+ }
+ actor, err := h.actor(r)
+ if err != nil {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadRequest)
+ return
+ }
+ title := strings.TrimSpace(r.PostFormValue("title"))
+ if title == "" {
+ title = rastrillo.T(r, "docs.document.untitled")
+ }
+ if len(title) > 1024 {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusBadRequest)
+ return
+ }
+ if err := h.store.Rename(r.Context(), actor, doc, title); err != nil {
+ if errors.Is(err, ErrNotWritable) || errors.Is(err, gorm.ErrRecordNotFound) {
+ http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound)
+ return
+ }
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
diff --git a/internal/docs/migrations/0008_document_creation_token.sql b/internal/docs/migrations/0008_document_creation_token.sql
new file mode 100644
index 0000000..8c5e65c
--- /dev/null
+++ b/internal/docs/migrations/0008_document_creation_token.sql
@@ -0,0 +1,2 @@
+ALTER TABLE documents ADD COLUMN creation_token TEXT;
+CREATE UNIQUE INDEX documents_creation ON documents(owner_id, creation_token);
diff --git a/internal/docs/models.go b/internal/docs/models.go
index 6758361..bfc6c6f 100644
--- a/internal/docs/models.go
+++ b/internal/docs/models.go
@@ -49,7 +49,8 @@ type Document struct {
Sqid string `gorm:"column:sqid;uniqueIndex;not null"`
Title string `gorm:"column:title;not null"`
- OwnerID int64 `gorm:"column:owner_id;not null;index"`
+ OwnerID int64 `gorm:"column:owner_id;not null;index;uniqueIndex:documents_creation,priority:1"`
+ CreationToken *string `gorm:"column:creation_token;uniqueIndex:documents_creation,priority:2"`
GUID string `gorm:"column:guid;not null"`
ClientID uint64 `gorm:"column:client_id;not null"`
diff --git a/internal/docs/native_test.go b/internal/docs/native_test.go
index 7402a12..d0cbf19 100644
--- a/internal/docs/native_test.go
+++ b/internal/docs/native_test.go
@@ -160,3 +160,54 @@ func TestNativePublicLinkShownOnceAndAnonymousRefused(t *testing.T) {
t.Fatalf("anonymous native creation: %d", got.Code)
}
}
+
+func TestNativeDraftCreationRetryAndRename(t *testing.T) {
+ app := members(t, "alice@example.com,bob@example.com")
+ alice := signInCookies(t, app, "alice@example.com")
+ bob := signInCookies(t, app, "bob@example.com")
+ const form = "creation_token=dd1f5245-2d96-4f11-af6b-350de8c32896"
+ // Concurrent/lost-response retries must all resolve the same owner-scoped token.
+ results := make(chan *httptest.ResponseRecorder, 8)
+ for i := 0; i < 8; i++ {
+ go func() { results <- nativeRequest(app, alice, "POST", "/documents", form) }()
+ }
+ var sqid string
+ for i := 0; i < 8; i++ {
+ got := <-results
+ var doc struct{ Sqid string }
+ if got.Code != 201 || json.Unmarshal(got.Body.Bytes(), &doc) != nil {
+ t.Fatalf("create: %d %s", got.Code, got.Body.String())
+ }
+ if sqid != "" && sqid != doc.Sqid {
+ t.Fatal("retry created another document")
+ }
+ sqid = doc.Sqid
+ }
+ path := "/d/" + sqid
+ if got := nativeRequest(app, alice, "POST", path+"/title", "title=Renamed"); got.Code != 204 {
+ t.Fatalf("rename: %d %s", got.Code, got.Body.String())
+ }
+ var replay struct{ Sqid, Title string }
+ got := nativeRequest(app, alice, "POST", "/documents", form+"&title=Old")
+ if json.Unmarshal(got.Body.Bytes(), &replay) != nil || replay.Sqid != sqid || replay.Title != "Renamed" {
+ t.Fatalf("replay overwrote title: %s", got.Body.String())
+ }
+ if got := nativeRequest(app, bob, "POST", path+"/title", "title=Stolen"); got.Code != 404 {
+ t.Fatalf("stranger rename: %d", got.Code)
+ }
+ nativeRequest(app, alice, "POST", path+"/share", "address=bob%40example.com&role=reader")
+ if got := nativeRequest(app, bob, "POST", path+"/title", "title=Reader"); got.Code != 404 {
+ t.Fatalf("reader rename: %d", got.Code)
+ }
+ nativeRequest(app, alice, "POST", path+"/share", "address=bob%40example.com&role=writer")
+ if got := nativeRequest(app, bob, "POST", path+"/title", "title=Writer"); got.Code != 204 {
+ t.Fatalf("writer rename: %d", got.Code)
+ }
+ if got := nativeRequest(app, alice, "POST", path+"/title", "title="+strings.Repeat("a", 1025)); got.Code != 400 {
+ t.Fatalf("oversized title: %d", got.Code)
+ }
+ other := nativeRequest(app, bob, "POST", "/documents", form)
+ if json.Unmarshal(other.Body.Bytes(), &replay) != nil || replay.Sqid == sqid {
+ t.Fatal("creation token escaped owner scope")
+ }
+}
diff --git a/internal/docs/store.go b/internal/docs/store.go
index f96c9bd..414ea2c 100644
--- a/internal/docs/store.go
+++ b/internal/docs/store.go
@@ -279,6 +279,12 @@ func (s *Store) seedIfEnabled(ctx context.Context, newOwner int64, email string)
// SKILL.md §3: "Creating takes the owner from the session, never the
// form."
func (s *Store) Create(ctx context.Context, owner int64, title string) (*Document, error) {
+ return s.CreateDraft(ctx, owner, title, "")
+}
+
+// CreateDraft makes retries after a lost acknowledgement return the same document.
+// The token is scoped to the authenticated owner, never a document capability.
+func (s *Store) CreateDraft(ctx context.Context, owner int64, title, token string) (*Document, error) {
actor := s.ownerActor(ctx, owner)
if err := s.requireMember(ctx, actor); err != nil {
return nil, err
@@ -309,6 +315,21 @@ func (s *Store) Create(ctx context.Context, owner int64, title string) (*Documen
if err := s.requireMember(ctx, actor); err != nil {
return err
}
+ if token != "" {
+ var existing Document
+ err := tx.Where("owner_id = ? AND creation_token = ?", owner, token).First(&existing).Error
+ if err == nil {
+ if existing.DeletedAt != nil {
+ return gorm.ErrRecordNotFound
+ }
+ *doc = existing
+ return s.requireMember(ctx, actor)
+ }
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
+ return err
+ }
+ doc.CreationToken = &token
+ }
if err := tx.Create(doc).Error; err != nil {
return err
}
@@ -891,3 +912,19 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, actor Actor, upd
return assignedSeq, nil
}
+
+// Rename repeats write authorization within the transaction, as ApplyUpdate does.
+func (s *Store) Rename(ctx context.Context, actor Actor, doc *Document, title string) error {
+ if err := s.checkPermission(ctx, s.db.WithContext(ctx), actor, doc.ID, permissionWrite); err != nil {
+ return err
+ }
+ return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := s.checkPermission(ctx, tx, actor, doc.ID, permissionWrite); err != nil {
+ return err
+ }
+ if err := s.permissionScope(ctx, tx.Model(&Document{}), actor, permissionWrite).Where("documents.id = ? AND documents.deleted_at IS NULL", doc.ID).Updates(map[string]any{"title": title, "updated_at": time.Now().UTC()}).Error; err != nil {
+ return err
+ }
+ return s.checkPermission(ctx, tx, actor, doc.ID, permissionWrite)
+ })
+}
diff --git a/native/INSTANT-DOCUMENTS.md b/native/INSTANT-DOCUMENTS.md
new file mode 100644
index 0000000..19526d1
--- /dev/null
+++ b/native/INSTANT-DOCUMENTS.md
@@ -0,0 +1,28 @@
+# Immediate native documents
+
+New reserves an account-bound window and opens a local untitled replica. The
+initial paragraph and first editable generation are ready before the text view
+is exposed; no server request blocks the editor. Attachment preserves the
+window ID, text view, replica, generations, and selection.
+
+The draft owns a UUID creation token. POST /documents accepts creation_token;
+migration 0008 adds a nullable token with a unique (owner_id, creation_token)
+index. Creation and lookup run under the writer transaction. Replays return the
+existing document and never overwrite a later title. The token is not authority:
+creation still requires membership and derives its owner from the session.
+
+Drafts count as unsaved before initialization. Save/close awaits creation and
+queued content/title writes; failure retains the draft and token for retry.
+Discard changes the epoch so late responses cannot attach or upload discarded
+content. A server may already have created an empty document before cancellation.
+Local draft state is held in memory with the existing close/quit guards.
+
+Double-clicking the title or File > Rename document opens the same title input.
+The model owns that buffer so Save/close also commits it; Escape cancels it.
+Titles over 1024 UTF-8 bytes remain editable with an error. Rename uses the
+existing document write authorization inside the authenticated, CSRF-protected
+POST /d/{sqid}/title route. Pending titles survive capability refreshes.
+
+Deploy the backend migration/routes before publishing the native client. Older
+servers do not provide idempotent creation. Tests are documented in
+smoke/document-window/README.md. Design and implementation reviewed by GPT-6 Astra.
diff --git a/native/apple/Sources/DocsApp/DocsAccountWindow.swift b/native/apple/Sources/DocsApp/DocsAccountWindow.swift
index fe2a50c..4ae4781 100644
--- a/native/apple/Sources/DocsApp/DocsAccountWindow.swift
+++ b/native/apple/Sources/DocsApp/DocsAccountWindow.swift
@@ -19,6 +19,10 @@ struct DocsAccountWindow: View {
guard connection.activeAccountID == account, windows.canSignOut(account) else { return }
windows.entries.removeAll { $0.account == account }
connection.signOut()
+ }, createDocument: {
+ guard connection.activeAccountID == account else { return }
+ let id = windows.reserveDraft(account: account, client: client)
+ openWindow(id: "document", value: id)
}, canSignOut: windows.canSignOut(account), createRequested: $windows.createRequested, accountsRequested: $windows.accountsRequested, openDocument: { document, title in
guard connection.activeAccountID == account else { return }
let id = windows.reserve(account: account, document: document, title: title, client: client)
diff --git a/native/apple/Sources/DocsApp/DocsDocumentWindow.swift b/native/apple/Sources/DocsApp/DocsDocumentWindow.swift
index 7c8e090..0fdf391 100644
--- a/native/apple/Sources/DocsApp/DocsDocumentWindow.swift
+++ b/native/apple/Sources/DocsApp/DocsDocumentWindow.swift
@@ -8,7 +8,7 @@ struct DocsDocumentWindow: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
if let entry = windows.entries.first(where: { $0.id == id }) {
- DocsWorkspace(client: entry.client, signOut: {}, documentID: entry.document)
+ DocsWorkspace(client: entry.client, signOut: {}, documentID: entry.document, newDocument: entry.isDraft, documentChanged: { document, title in windows.attach(entry.id, document: document, title: title) })
.id(entry.id)
.focusedSceneValue(\.docsWindowAccount, entry.account)
.onAppear { windows.setOpen(entry.id, true) }
diff --git a/native/apple/Sources/DocsApp/DocsWindowCommands.swift b/native/apple/Sources/DocsApp/DocsWindowCommands.swift
index 5933678..28ea9d7 100644
--- a/native/apple/Sources/DocsApp/DocsWindowCommands.swift
+++ b/native/apple/Sources/DocsApp/DocsWindowCommands.swift
@@ -20,8 +20,13 @@ struct DocsWindowCommands: Commands {
CommandGroup(replacing: .newItem) {
Button(docsMacString("New document")) {
- windows.createRequested = true
- openWindow(id: "home")
+ if let entry = windows.entries.first(where: { $0.account == account && $0.isOpen }) {
+ let id = windows.reserveDraft(account: entry.account, client: entry.client)
+ openWindow(id: "document", value: id)
+ } else {
+ windows.createRequested = true
+ openWindow(id: "home")
+ }
}.keyboardShortcut("n")
Menu(docsMacString("Recent")) {
ForEach(windows.entries.filter { $0.account == (account ?? windows.activeAccount) }.prefix(20)) { entry in
diff --git a/native/apple/Sources/DocsApp/DocsWindows.swift b/native/apple/Sources/DocsApp/DocsWindows.swift
index 59361f3..41a682d 100644
--- a/native/apple/Sources/DocsApp/DocsWindows.swift
+++ b/native/apple/Sources/DocsApp/DocsWindows.swift
@@ -1,5 +1,6 @@
#if os(macOS)
import Foundation
+import DocsKit
import Observation
import RastrilloApple
import SwiftUI
@@ -9,10 +10,11 @@ import SwiftUI
struct Entry: Identifiable {
let id: UUID
let account: String
- let document: String
+ var document: String
let client: NativeHTTP
var title: String
var isOpen = false
+ var isDraft = false
}
var entries: [Entry] = []
var activeAccount: String?
@@ -30,6 +32,17 @@ import SwiftUI
entries.insert(entry, at: 0)
return entry.id
}
+ func reserveDraft(account: String, client: NativeHTTP) -> UUID {
+ let id = UUID()
+ entries.insert(Entry(id: id, account: account, document: "draft-" + id.uuidString, client: client, title: docsMacString("Untitled document"), isOpen: true, isDraft: true), at: 0)
+ return id
+ }
+ func attach(_ id: UUID, document: String, title: String) {
+ guard let index = entries.firstIndex(where: { $0.id == id }) else { return }
+ entries[index].document = document; entries[index].title = title; entries[index].isDraft = false
+ let account = entries[index].account
+ entries.removeAll { $0.id != id && !$0.isOpen && $0.account == account && $0.document == document }
+ }
func updateRecents(_ documents: [(String, String)], account: String, client: NativeHTTP) {
for (document, title) in documents {
if let index = entries.firstIndex(where: { $0.account == account && $0.client.origin == client.origin && $0.document == document }) {
@@ -40,7 +53,9 @@ import SwiftUI
}
}
func setOpen(_ id: UUID, _ value: Bool) {
- if let index = entries.firstIndex(where: { $0.id == id }) { entries[index].isOpen = value }
+ if let index = entries.firstIndex(where: { $0.id == id }) {
+ if !value && entries[index].isDraft { entries.remove(at: index) } else { entries[index].isOpen = value }
+ }
}
func canSignOut(_ account: String?) -> Bool { !entries.contains { $0.account == account && $0.isOpen } }
}
diff --git a/native/apple/Sources/DocsKit/DocsDocumentMenu.swift b/native/apple/Sources/DocsKit/DocsDocumentMenu.swift
index 11188b0..728a9b8 100644
--- a/native/apple/Sources/DocsKit/DocsDocumentMenu.swift
+++ b/native/apple/Sources/DocsKit/DocsDocumentMenu.swift
@@ -6,19 +6,21 @@ public struct DocsDocumentMenu: View {
public init() {}
public var body: some View {
Group {
+ Button { editor?.rename() } label: { Text("docs.document.rename", bundle: DocsResources.bundle) }
+ .keyboardShortcut("r", modifiers: [.command, .shift]).disabled(editor?.model.detail?.can_write != true)
Button(docsMacString("Save")) { if let editor { Task { _ = await editor.model.saveAll() } } }
.keyboardShortcut("s").disabled(editor == nil)
Menu(docsMacString("Export")) {
Button(docsMacString("PDF…")) { editor?.exportRequest.wrappedValue = "pdf" }
Button(docsMacString("Markdown…")) { editor?.exportRequest.wrappedValue = "markdown" }
- }.disabled(editor == nil)
+ }.disabled(editor == nil || editor?.model.isDraft == true)
Button(docsMacString("Share…")) {
if let editor { Task { if await editor.model.saveAll() { editor.sharing.wrappedValue = true } } }
}.disabled(editor?.model.detail?.can_share != true)
Divider()
- Button(docsMacString("Print…")) { editor?.exportRequest.wrappedValue = "print" }.keyboardShortcut("p").disabled(editor == nil)
+ Button(docsMacString("Print…")) { editor?.exportRequest.wrappedValue = "print" }.keyboardShortcut("p").disabled(editor == nil || editor?.model.isDraft == true)
}
}
diff --git a/native/apple/Sources/DocsKit/DocsEditorContext.swift b/native/apple/Sources/DocsKit/DocsEditorContext.swift
index 4f54515..9b9ac73 100644
--- a/native/apple/Sources/DocsKit/DocsEditorContext.swift
+++ b/native/apple/Sources/DocsKit/DocsEditorContext.swift
@@ -5,6 +5,7 @@ import SwiftUI
struct DocsEditorContext {
let model: DocsModel
+ let rename: () -> Void
let client: NativeHTTP
let zoom: Binding<Double>
let paged: Binding<Bool>
diff --git a/native/apple/Sources/DocsKit/DocsModel.swift b/native/apple/Sources/DocsKit/DocsModel.swift
index 84ee7bc..545f7f0 100644
--- a/native/apple/Sources/DocsKit/DocsModel.swift
+++ b/native/apple/Sources/DocsKit/DocsModel.swift
@@ -19,6 +19,14 @@ import RastrilloApple
var loading = false
var creatingDocument = false
private var lifetime = UUID()
+ private(set) var draftToken: String?
+ private var creationTask: Task<Void, Never>?
+ private var pendingTitle: String?
+ var renameInput: String?
+ private var titleRevision: UInt64 = 0
+ var isDraft: Bool { draftToken != nil }
+ var editorIdentity = UUID()
+
var selection = NSRange(location: 0, length: 0)
var typingMarks: [String: JSONValue]?
private var replica: CRDTReplica?
@@ -31,9 +39,9 @@ import RastrilloApple
private var documentEpoch = UUID()
private let publications = DocumentPublication()
private let client: NativeHTTP
- init(client: NativeHTTP) { self.client = client }
+ init(client: NativeHTTP, draft: Bool = false) { self.client = client; if draft { draftToken = UUID().uuidString } }
var hasPendingInput: Bool { !editing.isEmpty || editingTask != nil }
- var hasUnsavedChanges: Bool { composing || !pending.isEmpty || !editing.isEmpty || editingTask != nil }
+ var hasUnsavedChanges: Bool { (renameInput != nil && renameInput != detail?.title) || isDraft || pendingTitle != nil || composing || !pending.isEmpty || !editing.isEmpty || editingTask != nil }
func load(query: String = "") async {
let request = UUID(); listRequest = request
listState = .loading
@@ -49,26 +57,84 @@ import RastrilloApple
listState = .failed; errorKey = "docs.error.generic"
}
}
- @discardableResult func create(title: String, openCreated: Bool = true) async -> DocumentSummary? {
- guard !creatingDocument, !hasUnsavedChanges else { return nil }
- let lifetime = self.lifetime
+ /// The editor and its initial paragraph are local. Server acknowledgement
+ /// only attaches an identity; it never reconstructs the editor or selection.
+ func newDraft() async {
+ guard detail == nil, replica == nil else { return }
+ if draftToken == nil { draftToken = UUID().uuidString }
+ let epoch = documentEpoch
+ let guid = UUID().uuidString
+ let local = CRDTReplica(guid: guid)
+ loading = true
+ defer { if documentEpoch == epoch { loading = false } }
+ do {
+ _ = try await local.open(update: Data())
+ let (update, _) = try await local.command(["op": .string("insert_element"), "at": .number(0), "type": .string("paragraph")])
+ guard documentEpoch == epoch else { await local.close(); return }
+ replica = local; pending.append(update)
+ detail = DocumentDetail(sqid: "draft-" + draftToken!, title: String(localized: "docs.document.untitled", bundle: DocsResources.bundle), guid: guid, can_write: true, can_share: false)
+ await publish()
+ guard documentEpoch == epoch else { return }
+ startCreation()
+ } catch { if documentEpoch == epoch { errorKey = "native.pending" } }
+ }
+ private func startCreation() {
+ guard creationTask == nil, draftToken != nil else { return }
creatingDocument = true
- defer { creatingDocument = false }
+ let epoch = documentEpoch
+ creationTask = Task {
+ await attachDraft(epoch: epoch)
+ if documentEpoch == epoch { creationTask = nil; creatingDocument = false }
+ }
+ }
+ private func attachDraft(epoch: UUID) async {
+ guard let token = draftToken, let local = replica else { return }
do {
- var form = URLComponents(); form.queryItems = [URLQueryItem(name: "title", value: title)]
- let (data, status) = try await client.response("/documents", method: "POST", body: Data((form.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B").utf8), contentType: "application/x-www-form-urlencoded")
+ let body = Self.form(["title": detail?.title ?? "", "creation_token": token])
+ let (data, status) = try await client.response("/documents", method: "POST", body: body, contentType: "application/x-www-form-urlencoded")
guard status == 201 else { throw CRDTReplica.Failure.rejected }
let item = try JSONDecoder().decode(DocumentSummary.self, from: data)
- guard self.lifetime == lifetime else { return nil }
- await load()
- guard self.lifetime == lifetime else { return nil }
- if openCreated { await open(item.sqid) }
- return item
- } catch { errorKey = "docs.error.generic"; return nil }
+ guard documentEpoch == epoch else { return }
+ let attached: DocumentDetail = try await client.request("/d/\(item.sqid)")
+ let reply = try await client.responseDetails("/d/\(item.sqid)/updates", maxBytes: 8 * 1024 * 1024)
+ guard reply.status == 200, let seq = Int64(reply.header("Docs-Seq") ?? ""), attached.can_write else { throw CRDTReplica.Failure.rejected }
+ guard documentEpoch == epoch else { return }
+ if !reply.data.isEmpty { _ = try await local.apply(reply.data) }
+ guard documentEpoch == epoch else { return }
+ detail = DocumentDetail(sqid: attached.sqid, title: pendingTitle ?? attached.title, guid: attached.guid, can_write: attached.can_write, can_share: attached.can_share)
+ draftToken = nil; sequence = seq; errorKey = nil
+ // Do not publish while native callbacks are still in flight.
+ if !hasPendingInput { await publish() }
+ guard documentEpoch == epoch else { return }
+ streamTask = Task { [weak self] in await self?.listen(item.sqid) }
+ await flush()
+ } catch { if documentEpoch == epoch { errorKey = "native.pending" } }
+ }
+ private static func form(_ values: [String: String]) -> Data {
+ var form = URLComponents()
+ form.queryItems = values.map { URLQueryItem(name: $0.key, value: $0.value) }
+ return Data((form.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B").utf8)
+ }
+ @discardableResult func commitRename() -> Bool {
+ guard let value = renameInput else { return true }
+ guard rename(value) else { return false }
+ renameInput = nil
+ return true
+ }
+ @discardableResult func rename(_ value: String) -> Bool {
+ guard let detail, detail.can_write else { return false }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ let title = trimmed.isEmpty ? String(localized: "docs.document.untitled", bundle: DocsResources.bundle) : trimmed
+ guard title.utf8.count <= 1024 else { errorKey = "docs.error.generic"; return false }
+ guard title != detail.title else { return true }
+ self.detail = DocumentDetail(sqid: detail.sqid, title: title, guid: detail.guid, can_write: detail.can_write, can_share: detail.can_share)
+ pendingTitle = title; titleRevision &+= 1
+ Task { await flush() }
+ return true
}
func open(_ sqid: String) async {
guard !hasUnsavedChanges else { errorKey = "native.pending"; return }
- let epoch = UUID(); documentEpoch = epoch
+ let epoch = UUID(); documentEpoch = epoch; editorIdentity = UUID()
streamTask?.cancel(); streamTask = nil
let previous = replica; replica = nil; detail = nil; projection = nil
loading = true; errorKey = nil
@@ -83,7 +149,7 @@ import RastrilloApple
let replica = CRDTReplica(guid: detail.guid)
let projection = try await replica.open(update: reply.data)
guard documentEpoch == epoch else { await replica.close(); return }
- self.replica = replica; self.projection = projection; self.detail = detail; sequence = seq
+ self.replica = replica; self.detail = detail; sequence = seq
projectionGeneration = 0; renderedGeneration = 0
selection = NSRange(location: 0, length: 0); typingMarks = nil
composing = false; inputPaused = false
@@ -151,6 +217,11 @@ import RastrilloApple
} catch { if documentEpoch == epoch { errorKey = "native.recovered" } }
}
func retry() async {
+ if isDraft {
+ if replica == nil { await newDraft() } else { startCreation() }
+ await creationTask?.value
+ return
+ }
if inputPaused {
let epoch = documentEpoch
if let detail { await refreshAccess(detail.sqid, epoch: epoch) }
@@ -170,6 +241,8 @@ import RastrilloApple
/// update remains part of the shared document, as in the web editor.
func discardAll() async {
documentEpoch = UUID()
+ creationTask?.cancel(); creationTask = nil; creatingDocument = false
+ draftToken = nil; pendingTitle = nil; renameInput = nil
streamTask?.cancel(); streamTask = nil
editingTask?.cancel(); editingTask = nil
pending.removeAll(); editing.removeAll(); saving = false
@@ -180,16 +253,27 @@ import RastrilloApple
if let previous { await previous.close() }
}
func saveAll() async -> Bool {
+ guard commitRename() else { return false }
+ if isDraft { startCreation(); await creationTask?.value }
if let editingTask { await editingTask.value }
while saving { try? await Task.sleep(for: .milliseconds(20)) }
await flush()
return !hasUnsavedChanges
}
func flush() async {
- guard !saving, let detail, let replica else { return }; saving = true
+ guard !isDraft, !saving, let detail, let replica else { return }; saving = true
let epoch = documentEpoch
defer { if documentEpoch == epoch { saving = false } }
- while !pending.isEmpty {
+ while !pending.isEmpty || pendingTitle != nil {
+ if let title = pendingTitle {
+ do {
+ let (_, status) = try await client.response("/d/\(detail.sqid)/title", method: "POST", body: Self.form(["title": title]), contentType: "application/x-www-form-urlencoded")
+ guard documentEpoch == epoch else { return }
+ guard status == 204 else { throw CRDTReplica.Failure.rejected }
+ if pendingTitle == title { pendingTitle = nil }
+ } catch { if documentEpoch == epoch { errorKey = "native.pending" }; return }
+ }
+ if pending.isEmpty { continue }
do {
// A slow request must not leave one full round trip per keypress.
// Keep the captured prefix until acknowledgement; edits appended
@@ -255,12 +339,17 @@ import RastrilloApple
}
/// Revalidate capability after stream/write rejection while retaining the draft.
private func refreshAccess(_ sqid: String, epoch: UUID) async {
+ let revision = titleRevision
+ let hadPendingTitle = pendingTitle != nil
do {
let reply = try await client.responseDetails("/d/\(sqid)", maxBytes: 1024 * 1024)
guard documentEpoch == epoch else { return }
if reply.status == 200 {
let current = try JSONDecoder().decode(DocumentDetail.self, from: reply.data)
- detail = current
+ // The GET may have read its title before a rename acknowledged.
+ // Clearing pendingTitle must not let that old reply win afterward.
+ let title = pendingTitle ?? ((hadPendingTitle || titleRevision != revision) ? (detail?.title ?? current.title) : current.title)
+ detail = DocumentDetail(sqid: current.sqid, title: title, guid: current.guid, can_write: current.can_write, can_share: current.can_share)
if !current.can_write { inputPaused = true }
} else if [401, 403, 404].contains(reply.status) {
inputPaused = true
diff --git a/native/apple/Sources/DocsKit/DocsWorkspace.swift b/native/apple/Sources/DocsKit/DocsWorkspace.swift
index a41cdd0..5c28ab3 100644
--- a/native/apple/Sources/DocsKit/DocsWorkspace.swift
+++ b/native/apple/Sources/DocsKit/DocsWorkspace.swift
@@ -3,8 +3,11 @@ import SwiftUI
@MainActor public struct DocsWorkspace: View {
@State private var model: DocsModel
@State private var query = ""
- @State private var newTitle = ""
- @State private var creating = false
+ @State private var renaming = false
+ @FocusState private var titleFocused: Bool
+ private let newDocument: Bool
+ private let createDocument: (() -> Void)?
+ private let documentChanged: ((String, String) -> Void)?
@AppStorage("docs.view.paged") private var paged = true
@State private var sharing = false
@State private var exportRequest: String?
@@ -24,9 +27,10 @@ import SwiftUI
private let signOut: () -> Void
private let accounts: (() -> Void)?
private let client: NativeHTTP
- public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: String? = nil, canSignOut: Bool = true, createRequested: Binding<Bool> = .constant(false), accountsRequested: Binding<Bool> = .constant(false), openDocument: ((String, String) -> Void)? = nil, updateRecents: (([(String, String)]) -> Void)? = nil) {
+ public init(client: NativeHTTP, accounts: (() -> Void)? = nil, signOut: @escaping () -> Void, documentID: String? = nil, newDocument: Bool = false, createDocument: (() -> Void)? = nil, documentChanged: ((String, String) -> Void)? = nil, canSignOut: Bool = true, createRequested: Binding<Bool> = .constant(false), accountsRequested: Binding<Bool> = .constant(false), openDocument: ((String, String) -> Void)? = nil, updateRecents: (([(String, String)]) -> Void)? = nil) {
self.updateRecents = updateRecents; self.canSignOut = canSignOut; self._createRequested = createRequested; self._accountsRequested = accountsRequested; self.documentID = documentID; self.openDocument = openDocument
- _model = State(initialValue: DocsModel(client: client)); self.signOut = signOut; self.accounts = accounts; self.client = client
+ self.newDocument = newDocument; self.createDocument = createDocument; self.documentChanged = documentChanged
+ _model = State(initialValue: DocsModel(client: client, draft: newDocument)); self.signOut = signOut; self.accounts = accounts; self.client = client
}
private var library: some View {
List {
@@ -48,7 +52,7 @@ import SwiftUI
ToolbarItemGroup {
DocsJumpControls(presented: $jumping)
- if model.canCreate { Button { creating = true } label: { Label { Text("docs.list.create.label", bundle: DocsResources.bundle) } icon: { Image(systemName: "plus") } }.accessibilityIdentifier("docs.create") }
+ if model.canCreate { Button { beginDocument() } label: { Label { Text("docs.list.create.label", bundle: DocsResources.bundle) } icon: { Image(systemName: "plus") } }.accessibilityIdentifier("docs.create") }
Button { if !model.hasUnsavedChanges && !model.creatingDocument { signOut() } } label: { Text("docs.signout.label", bundle: DocsResources.bundle) }.disabled(model.hasUnsavedChanges || model.creatingDocument)
}
}
@@ -56,7 +60,7 @@ import SwiftUI
public var body: some View {
Group {
#if os(macOS)
- if documentID != nil { editor } else { home }
+ if documentID != nil || newDocument { editor } else { home }
#else
NavigationSplitView(preferredCompactColumn: $column) { library } detail: { editor }
#endif
@@ -69,9 +73,13 @@ import SwiftUI
if ready { accountsRequested = false; accounts?() }
}
.onChange(of: createRequested, initial: true) { _, requested in
- if requested && documentID == nil { creating = true; createRequested = false }
+ if requested && documentID == nil { beginDocument(); createRequested = false }
+ }
+ .task { if newDocument { await model.newDraft() } else if let documentID { await model.open(documentID) } else { await model.load() } }
+ .onChange(of: model.renameInput) { _, value in if value == nil { renaming = false } }
+ .onChange(of: [model.detail?.sqid, model.detail?.title]) {
+ if !model.isDraft, let detail = model.detail { documentChanged?(detail.sqid, detail.title) }
}
- .task { if let documentID { await model.open(documentID) } else { await model.load() } }
.sheet(isPresented: $jumping) { DocsJumpSheet(model: model, client: client) }
.onDisappear { Task { await model.stop() } }
#if os(macOS)
@@ -87,17 +95,48 @@ import SwiftUI
.sheet(isPresented: $sharing) {
if let detail = model.detail { DocsSharingView(client: client, sqid: detail.sqid) }
}
- .sheet(isPresented: $creating) {
- Form {
- TextField(String(localized: "docs.list.create.placeholder", bundle: DocsResources.bundle), text: $newTitle)
- Button { let title = newTitle; newTitle = ""; creating = false; Task {
+ }
+ private func beginDocument() {
+ #if os(macOS)
+ createDocument?()
+ #else
+ Task {
+ guard await model.saveAll() else { return }
+ await model.discardAll()
+ await model.newDraft()
+ }
+ #endif
+ }
+ private func beginRename() {
+ model.renameInput = model.detail?.title ?? ""
+ renaming = true; titleFocused = true
+ }
+ private func finishRename() {
+ guard renaming else { return }
+ if model.commitRename() { renaming = false }
+ }
+ private var documentTitle: some View {
+ Group {
+ if renaming {
+ TextField(String(localized: "docs.list.create.placeholder", bundle: DocsResources.bundle), text: Binding(get: { model.renameInput ?? model.detail?.title ?? "" }, set: { model.renameInput = $0 }))
+ .accessibilityIdentifier("docs.document.title")
+ .textFieldStyle(.plain).frame(minWidth: 180, maxWidth: 480)
+ .focused($titleFocused).onSubmit { finishRename() }
+ .onChange(of: titleFocused) { _, focused in if !focused { finishRename() } }
#if os(macOS)
- if let item = await model.create(title: title, openCreated: false) { openDocument?(item.sqid, item.title) }
- #else
- await model.create(title: title)
+ .onExitCommand { model.renameInput = nil; renaming = false }
#endif
- } } label: { Text("docs.list.create.label", bundle: DocsResources.bundle) }.accessibilityIdentifier("docs.create.submit")
- }.padding().presentationDetents([.medium])
+ } else {
+ Text(model.detail?.title ?? " ")
+ .lineLimit(1).frame(minHeight: 20)
+ .onTapGesture(count: 2) { if model.detail?.can_write == true { beginRename() } }
+ .contextMenu {
+ Button { beginRename() } label: { Text("docs.document.rename", bundle: DocsResources.bundle) }
+ .disabled(model.detail?.can_write != true)
+ .keyboardShortcut("r", modifiers: [.command, .shift])
+ }
+ .accessibilityAction(named: Text("docs.document.rename", bundle: DocsResources.bundle)) { beginRename() }
+ }
}
}
private var editor: some View {
@@ -128,19 +167,20 @@ import SwiftUI
}
return DocumentCommands(model: model, content: text).perform(action)
}, selectionMoved: model.selectionMovedExplicitly, canAction: { action in model.detail?.can_write == true && !model.hasPendingInput && !model.composing && !model.inputPaused && (action == "undo" ? projection.canUndo : projection.canRedo) }, explicitCommandRevision: model.explicitCommandRevision, zoom: zoom)
- .id(model.detail?.sqid)
+ .id(model.editorIdentity)
.task(id: [documentPath] + DocsImageStore.sources(in: projection)) { await images.load(sources: DocsImageStore.sources(in: projection), client: client, documentPath: documentPath) }
} else {
ContentUnavailableView { Label { Text("native.open", bundle: DocsResources.bundle) } icon: { Image(systemName: "doc.text") } }
}
}.navigationTitle(model.detail?.title ?? "")
+ .toolbar { ToolbarItem(placement: .principal) { documentTitle } }
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
#if os(iOS)
.toolbar {
DocsJumpControls(presented: $jumping)
- if let detail = model.detail {
+ if let detail = model.detail, !model.isDraft {
Toggle(isOn: $paged) { Text("docs.editor.menu.view.paged", bundle: DocsResources.bundle) }
DocumentExportControls(client: client, sqid: detail.sqid, title: detail.title) {
return await model.saveAll()
@@ -155,7 +195,7 @@ import SwiftUI
}
#else
.background {
- if let detail = model.detail {
+ if let detail = model.detail, !model.isDraft {
DocumentExportControls(client: client, sqid: detail.sqid, title: detail.title, prepare: { await model.saveAll() }, request: $exportRequest)
.frame(width: 0, height: 0).hidden().accessibilityHidden(true)
}
@@ -183,7 +223,7 @@ import SwiftUI
.overlay(alignment: .leading) { Divider() }
}
}
- .focusedSceneValue(\.docsEditor, DocsEditorContext(model: model, client: client, zoom: $zoom, paged: $paged, inspecting: $inspecting, sharing: $sharing, exportRequest: $exportRequest))
+ .focusedSceneValue(\.docsEditor, DocsEditorContext(model: model, rename: beginRename, client: client, zoom: $zoom, paged: $paged, inspecting: $inspecting, sharing: $sharing, exportRequest: $exportRequest))
#endif
}
@@ -225,7 +265,7 @@ import SwiftUI
.onSubmit(of: .search) { Task { await model.load(query: query) } }
.focusedSceneValue(\.docsAccountActions, DocsAccountActions(accounts: accounts, signOut: signOut, canManage: !model.creatingDocument, canSignOut: canSignOut && !model.creatingDocument))
.toolbar {
- Button(docsMacString("New document"), systemImage: "plus") { creating = true }.disabled(!model.canCreate)
+ Button(docsMacString("New document"), systemImage: "plus") { beginDocument() }.disabled(!model.canCreate)
}
}
}
diff --git a/native/apple/Sources/DocsKit/Resources/ar.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/ar.lproj/Localizable.strings
index cc349a4..0bd2e88 100644
--- a/native/apple/Sources/DocsKit/Resources/ar.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/ar.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "إلغاء";
"native.discard.title" = "هل تريد تجاهل التغييرات غير المحفوظة؟";
"native.discard.action" = "تجاهل التغييرات";
+
+"docs.document.rename" = "إعادة تسمية المستند";
diff --git a/native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings
index b38b2b3..0f71091 100644
--- a/native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "تسجيل الخروج";
"Size" = "الحجم";
"Zoom" = "التكبير";
+
+"Untitled document" = "مستند بلا عنوان";
diff --git a/native/apple/Sources/DocsKit/Resources/bn.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/bn.lproj/Localizable.strings
index 00830d4..f047d80 100644
--- a/native/apple/Sources/DocsKit/Resources/bn.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/bn.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "বাতিল করুন";
"native.discard.title" = "অসংরক্ষিত পরিবর্তন বাতিল করবেন?";
"native.discard.action" = "পরিবর্তন বাতিল করুন";
+
+"docs.document.rename" = "নথির নাম পরিবর্তন করুন";
diff --git a/native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings
index ccde98e..e5ef63c 100644
--- a/native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "সাইন আউট করুন";
"Size" = "আকার";
"Zoom" = "জুম";
+
+"Untitled document" = "শিরোনামহীন নথি";
diff --git a/native/apple/Sources/DocsKit/Resources/en.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/en.lproj/Localizable.strings
index 1957c21..806b558 100644
--- a/native/apple/Sources/DocsKit/Resources/en.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/en.lproj/Localizable.strings
@@ -175,3 +175,5 @@
"native.close.pending.cancel" = "Cancel";
"native.discard.title" = "Discard unsaved changes?";
"native.discard.action" = "Discard changes";
+
+"docs.document.rename" = "Rename document";
diff --git a/native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings
index ce26f16..257c19e 100644
--- a/native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "Sign out";
"Size" = "Size";
"Zoom" = "Zoom";
+
+"Untitled document" = "Untitled document";
diff --git a/native/apple/Sources/DocsKit/Resources/es.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/es.lproj/Localizable.strings
index 884f4ec..9f6a7d4 100644
--- a/native/apple/Sources/DocsKit/Resources/es.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/es.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "Cancelar";
"native.discard.title" = "¿Descartar los cambios sin guardar?";
"native.discard.action" = "Descartar cambios";
+
+"docs.document.rename" = "Cambiar nombre del documento";
diff --git a/native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings
index a923884..3568620 100644
--- a/native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "Cerrar sesión";
"Size" = "Tamaño";
"Zoom" = "Zoom";
+
+"Untitled document" = "Documento sin título";
diff --git a/native/apple/Sources/DocsKit/Resources/ga.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/ga.lproj/Localizable.strings
index b1287de..cae893b 100644
--- a/native/apple/Sources/DocsKit/Resources/ga.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/ga.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "Cealaigh";
"native.discard.title" = "Scrios na hathruithe gan sábháil?";
"native.discard.action" = "Scrios athruithe";
+
+"docs.document.rename" = "Athainmnigh an doiciméad";
diff --git a/native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings
index 0ccaab5..4be5249 100644
--- a/native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "Sínigh amach";
"Size" = "Méid";
"Zoom" = "Súmáil";
+
+"Untitled document" = "Doiciméad gan teideal";
diff --git a/native/apple/Sources/DocsKit/Resources/hi.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/hi.lproj/Localizable.strings
index 153a256..5b468f9 100644
--- a/native/apple/Sources/DocsKit/Resources/hi.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/hi.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "रद्द करें";
"native.discard.title" = "बिना सहेजे बदलाव छोड़ें?";
"native.discard.action" = "बदलाव छोड़ें";
+
+"docs.document.rename" = "दस्तावेज़ का नाम बदलें";
diff --git a/native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings
index 0f82468..3919264 100644
--- a/native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "साइन आउट करें";
"Size" = "आकार";
"Zoom" = "ज़ूम";
+
+"Untitled document" = "बिना शीर्षक का दस्तावेज़";
diff --git a/native/apple/Sources/DocsKit/Resources/ja.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/ja.lproj/Localizable.strings
index 6aa890a..e02a00c 100644
--- a/native/apple/Sources/DocsKit/Resources/ja.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/ja.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "キャンセル";
"native.discard.title" = "未保存の変更を破棄しますか?";
"native.discard.action" = "変更を破棄";
+
+"docs.document.rename" = "ドキュメント名を変更";
diff --git a/native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings
index b074b76..1b9af06 100644
--- a/native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "サインアウト";
"Size" = "サイズ";
"Zoom" = "ズーム";
+
+"Untitled document" = "無題のドキュメント";
diff --git a/native/apple/Sources/DocsKit/Resources/pt.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/pt.lproj/Localizable.strings
index aaa738c..e2f7b7f 100644
--- a/native/apple/Sources/DocsKit/Resources/pt.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/pt.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "Cancelar";
"native.discard.title" = "Descartar alterações não salvas?";
"native.discard.action" = "Descartar alterações";
+
+"docs.document.rename" = "Renomear documento";
diff --git a/native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings
index 8062b59..a859c91 100644
--- a/native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "Terminar sessão";
"Size" = "Tamanho";
"Zoom" = "Zoom";
+
+"Untitled document" = "Documento sem título";
diff --git a/native/apple/Sources/DocsKit/Resources/ru.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/ru.lproj/Localizable.strings
index 193dd55..d7ff633 100644
--- a/native/apple/Sources/DocsKit/Resources/ru.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/ru.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "Отмена";
"native.discard.title" = "Отменить несохранённые изменения?";
"native.discard.action" = "Отменить изменения";
+
+"docs.document.rename" = "Переименовать документ";
diff --git a/native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings
index 9f07e95..83aa91e 100644
--- a/native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "Выйти";
"Size" = "Размер";
"Zoom" = "Масштаб";
+
+"Untitled document" = "Документ без названия";
diff --git a/native/apple/Sources/DocsKit/Resources/vi.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/vi.lproj/Localizable.strings
index 5a86ee7..d33401d 100644
--- a/native/apple/Sources/DocsKit/Resources/vi.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/vi.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "Hủy";
"native.discard.title" = "Hủy các thay đổi chưa lưu?";
"native.discard.action" = "Hủy thay đổi";
+
+"docs.document.rename" = "Đổi tên tài liệu";
diff --git a/native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings
index 68b9667..fb823d0 100644
--- a/native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "Đăng xuất";
"Size" = "Kích cỡ";
"Zoom" = "Thu phóng";
+
+"Untitled document" = "Tài liệu chưa đặt tên";
diff --git a/native/apple/Sources/DocsKit/Resources/yue.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/yue.lproj/Localizable.strings
index d86be15..a8db880 100644
--- a/native/apple/Sources/DocsKit/Resources/yue.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/yue.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "取消";
"native.discard.title" = "放棄未儲存嘅變更?";
"native.discard.action" = "放棄變更";
+
+"docs.document.rename" = "重新命名文件";
diff --git a/native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings
index 1779f5b..2657f81 100644
--- a/native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "登出";
"Size" = "大小";
"Zoom" = "縮放";
+
+"Untitled document" = "未命名文件";
diff --git a/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Localizable.strings b/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Localizable.strings
index 1faafe8..d01ecfb 100644
--- a/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Localizable.strings
+++ b/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Localizable.strings
@@ -171,3 +171,5 @@
"native.close.pending.cancel" = "取消";
"native.discard.title" = "放弃未保存的更改?";
"native.discard.action" = "放弃更改";
+
+"docs.document.rename" = "重命名文档";
diff --git a/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings b/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings
index 8cb3867..6db2e5c 100644
--- a/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings
+++ b/native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings
@@ -47,3 +47,5 @@
"Sign out" = "退出登录";
"Size" = "大小";
"Zoom" = "缩放";
+
+"Untitled document" = "无标题文档";
diff --git a/native/apple/Tests/DocsKitTests/DocumentDraftTests.swift b/native/apple/Tests/DocsKitTests/DocumentDraftTests.swift
new file mode 100644
index 0000000..ef989be
--- /dev/null
+++ b/native/apple/Tests/DocsKitTests/DocumentDraftTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+import RastrilloApple
+import XCTest
+@testable import DocsKit
+
+final class DocumentDraftTests: XCTestCase {
+ @MainActor func testImmediateDraftLostAcknowledgementRenameAndDiscard() async throws {
+ guard let origin = ProcessInfo.processInfo.environment["DOCS_DRAFT_TEST_ORIGIN"] else { throw XCTSkip("Run test-document-drafts.py") }
+ let client = NativeHTTP(origin: URL(string: origin)!)
+ let model = DocsModel(client: client, draft: true)
+ XCTAssertTrue(model.hasUnsavedChanges, "Protect the window before the first suspension")
+ let start = ContinuousClock.now
+ await model.newDraft()
+ XCTAssertLessThan(start.duration(to: .now), .milliseconds(500), "Opening cannot wait for the server")
+ XCTAssertNotNil(model.projection)
+ XCTAssertTrue(model.isDraft)
+ let identity = model.editorIdentity
+ for command in [
+ ["op": JSONValue.string("display_edit"), "at": .number(0), "length": .number(0), "text": .string("Draft text!")],
+ ["op": .string("display_edit"), "at": .number(10), "length": .number(1), "text": .string("")]
+ ] {
+ model.didRender(model.projectionGeneration)
+ XCTAssertTrue(model.enqueue(command))
+ while model.hasPendingInput { try await Task.sleep(for: .milliseconds(5)) }
+ }
+ model.rename("My document")
+ let deadline = ContinuousClock.now.advanced(by: .seconds(5))
+ while model.creatingDocument && .now < deadline { try await Task.sleep(for: .milliseconds(10)) }
+ XCTAssertTrue(model.isDraft, "The fixture drops the first creation acknowledgement")
+ XCTAssertEqual(model.projection?.children.map(\.plainText), ["Draft text"])
+ await model.retry()
+ let saved = await model.saveAll()
+ XCTAssertTrue(saved)
+ try await Task.sleep(for: .milliseconds(1000)) // Let the stale metadata reply finish.
+ XCTAssertEqual(model.editorIdentity, identity)
+ XCTAssertEqual(model.detail?.title, "My document")
+ struct Stats: Decodable { let documents: Int; let updates: [Data]; let title: String; let creations: Int; let metadata: Int }
+ let stats: Stats = try await client.request("/stats")
+ XCTAssertEqual(stats.documents, 1)
+ XCTAssertEqual(stats.creations, 2)
+ XCTAssertEqual(stats.title, "My document")
+ XCTAssertGreaterThanOrEqual(stats.metadata, 2, "Reconnection must refresh capabilities while rename is pending")
+ let peer = CRDTReplica(guid: "different-server-guid")
+ _ = try await peer.open(update: Data())
+ for update in stats.updates { _ = try await peer.apply(update) }
+ let projected = try await peer.projection()
+ XCTAssertEqual(projected.children.map(\.plainText), ["Draft text"], "Insertions and deletes converge across GUIDs")
+ await peer.close(); await model.stop()
+
+ let closing = DocsModel(client: client, draft: true)
+ await closing.newDraft()
+ closing.renameInput = String(repeating: "a", count: 1025)
+ let refused = await closing.saveAll()
+ XCTAssertFalse(refused)
+ XCTAssertNotNil(closing.renameInput, "Invalid title must remain editable")
+ closing.renameInput = "Saved on close"
+ let closed = await closing.saveAll()
+ XCTAssertTrue(closed, "Close-save must await creation and the pending title")
+ XCTAssertFalse(closing.isDraft)
+ await closing.stop()
+ let beforeDiscard: Stats = try await client.request("/stats")
+ let discarded = DocsModel(client: client, draft: true)
+ await discarded.newDraft()
+ try await Task.sleep(for: .milliseconds(100))
+ await discarded.discardAll()
+ try await Task.sleep(for: .milliseconds(1200))
+ XCTAssertNil(discarded.detail)
+ XCTAssertFalse(discarded.hasUnsavedChanges)
+ let after: Stats = try await client.request("/stats")
+ XCTAssertEqual(after.updates.count, beforeDiscard.updates.count, "Late creation must not upload a discarded draft")
+ }
+}
diff --git a/native/apple/Tests/DocsWindowTests/WindowRoutingTests.swift b/native/apple/Tests/DocsWindowTests/WindowRoutingTests.swift
index cabee15..e81c94a 100644
--- a/native/apple/Tests/DocsWindowTests/WindowRoutingTests.swift
+++ b/native/apple/Tests/DocsWindowTests/WindowRoutingTests.swift
@@ -24,4 +24,21 @@ import RastrilloApple
#expect(windows.entries.first(where: { $0.id == id })?.title == "Current title")
#expect(windows.entries.first(where: { $0.id == id })?.isOpen == false)
}
+@MainActor @Test func draftWindowsKeepAccountAndIdentityThroughAttachment() {
+ let windows = DocsWindows()
+ let client = NativeHTTP(origin: URL(string: "https://draft.invalid")!)
+ let first = windows.reserveDraft(account: "a", client: client)
+ let second = windows.reserveDraft(account: "a", client: client)
+ #expect(first != second)
+ #expect(!windows.canSignOut("a"))
+ windows.activeAccount = "b"
+ windows.attach(first, document: "created", title: "Renamed")
+ #expect(windows.entries.first(where: { $0.id == first })?.account == "a")
+ #expect(windows.entries.first(where: { $0.id == first })?.isDraft == false)
+ #expect(windows.reserve(account: "a", document: "created", title: "Renamed", client: client) == first)
+ windows.setOpen(second, false)
+ #expect(!windows.entries.contains(where: { $0.id == second }))
+ windows.setOpen(first, false)
+ #expect(windows.canSignOut("a"))
+}
#endif
diff --git a/native/scripts/test-document-drafts.py b/native/scripts/test-document-drafts.py
new file mode 100644
index 0000000..bb25747
--- /dev/null
+++ b/native/scripts/test-document-drafts.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+"""Exercise editable local drafts while creation is slow and its first reply is lost."""
+import base64
+import http.server
+import json
+import os
+from pathlib import Path
+import socket
+import subprocess
+import threading
+import time
+import urllib.parse
+root = Path(__file__).resolve().parents[2]
+class Handler(http.server.BaseHTTPRequestHandler):
+ documents = {}
+ updates = []
+ creations = 0
+ title = ""
+ renames = 0
+ metadata = 0
+ streams = 0
+ def reply(self, status, data=b"", **headers):
+ self.send_response(status)
+ self.send_header("Content-Length", str(len(data)))
+ for key, value in headers.items(): self.send_header(key, value)
+ self.end_headers()
+ try: self.wfile.write(data)
+ except (BrokenPipeError, ConnectionResetError): pass
+ def do_POST(self):
+ cls = type(self)
+ data = self.rfile.read(int(self.headers.get("Content-Length", "0")))
+ form = urllib.parse.parse_qs(data.decode()) if self.path == "/documents" or self.path.endswith("/title") else {}
+ if self.path == "/documents":
+ cls.creations += 1
+ number = cls.creations
+ token = form["creation_token"][0]
+ cls.documents.setdefault(token, str(len(cls.documents) + 1))
+ time.sleep(1)
+ if number == 1:
+ self.connection.shutdown(socket.SHUT_RDWR)
+ self.connection.close()
+ return
+ self.reply(201, json.dumps(dict(sqid=cls.documents[token], title="Untitled document")).encode())
+ elif self.path.endswith("/title"):
+ cls.renames += 1
+ time.sleep(0.3)
+ if cls.renames == 1:
+ self.reply(503)
+ else:
+ cls.title = form["title"][0]
+ self.reply(204)
+ else:
+ cls.updates.append(base64.b64encode(data).decode())
+ self.reply(204)
+ def do_GET(self):
+ cls = type(self)
+ if self.path == "/stats":
+ self.reply(200, json.dumps(dict(documents=len(cls.documents), updates=cls.updates, title=cls.title, creations=cls.creations, metadata=cls.metadata)).encode())
+ elif self.path.endswith("/stream"):
+ cls.streams += 1
+ if cls.streams == 1:
+ self.reply(503)
+ return
+ self.send_response(200); self.send_header("Content-Type", "text/event-stream"); self.end_headers()
+ self.wfile.write(b"data: 0\n\n"); self.wfile.flush(); time.sleep(5)
+ elif "/updates" in self.path:
+ self.reply(200, **{"Docs-Seq": "0"})
+ else:
+ cls.metadata += 1
+ if cls.metadata == 2: time.sleep(0.9) # Old title arrives after rename acknowledgement.
+ self.reply(200, json.dumps(dict(sqid=self.path.split("/")[-1], title="Untitled document", guid="server-guid", can_write=True, can_share=True)).encode())
+ def log_message(self, *_): pass
+with http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) as server:
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ result = subprocess.run(["swift", "test", "--package-path", str(root / "native/apple"), "--filter", "DocumentDraftTests"], env=dict(os.environ, DOCS_DRAFT_TEST_ORIGIN=f"http://127.0.0.1:{server.server_port}"), timeout=180)
+ server.shutdown()
+ raise SystemExit(result.returncode)
diff --git a/native/scripts/test-document-window.py b/native/scripts/test-document-window.py
index be127c5..1c586c3 100644
--- a/native/scripts/test-document-window.py
+++ b/native/scripts/test-document-window.py
@@ -14,6 +14,8 @@ fixture_name = os.environ.get("DOCS_SMOKE_FIXTURE", "letter")
assert fixture_name in {"letter", "documentation"}
fixture = Path(os.environ["DOCS_SMOKE_UPDATE"]).read_bytes() if "DOCS_SMOKE_UPDATE" in os.environ else (root / "internal/docs/seeddata" / (fixture_name + ".update.bin")).read_bytes()
+draft = os.environ.get("DOCS_SMOKE_DRAFT") == "1"
+
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
if self.path.endswith("/stream"):
@@ -25,7 +27,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
time.sleep(20)
return
if "/updates" in self.path:
- data = fixture
+ data = b"" if draft else fixture
else:
time.sleep(0.3) # Include loading -> editor layout transition.
data = json.dumps(dict(sqid="fixture", title="Window smoke fixture", guid="window-smoke",
@@ -38,8 +40,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", "0")))
- self.send_response(204)
- self.end_headers()
+ if self.path == "/documents":
+ time.sleep(2)
+ data = json.dumps(dict(sqid="fixture", title="Window smoke fixture")).encode()
+ self.send_response(201); self.send_header("Content-Length", str(len(data))); self.end_headers(); self.wfile.write(data)
+ else:
+ self.send_response(204)
+ self.end_headers()
def log_message(self, *_):
pass
diff --git a/native/smoke/document-window/README.md b/native/smoke/document-window/README.md
index b1dab97..112687c 100644
--- a/native/smoke/document-window/README.md
+++ b/native/smoke/document-window/README.md
@@ -29,3 +29,13 @@ The generated fixture has 60 independent paragraphs. Optional
`DOCS_SMOKE_SCREENSHOT=/tmp/docs-caret.png` captures the window after typing.
The same executable imports the real window command source through symlinks and
verifies Accounts/Sign out are in the app menu and Accounts reopens closed Home.
+
+To exercise immediate untitled creation, use `DOCS_SMOKE_DRAFT=1 python3 native/scripts/test-document-window.py`.
+The server deliberately waits two seconds. The test types before acknowledgement,
+asserts the same text view and content survive attachment, and saves an actively
+edited header title without pressing Return. Both modes then test Enter/caret
+geometry, inspector resizing, and native account menus.
+
+`python3 native/scripts/test-document-drafts.py` covers lost creation replies,
+retry identity, pending edits and deletes across different document GUIDs,
+rename during reconnect, oversized title retention, close-save, and discard.
diff --git a/native/smoke/document-window/Sources/Smoke.swift b/native/smoke/document-window/Sources/Smoke.swift
index 3ed74ad..15d3cda 100644
--- a/native/smoke/document-window/Sources/Smoke.swift
+++ b/native/smoke/document-window/Sources/Smoke.swift
@@ -11,7 +11,7 @@ import SwiftUI
Window("Smoke Home", id: "home") { Launcher(windows: windows) }
WindowGroup("Smoke Document", id: "document", for: String.self) { _ in
DocsWorkspace(client: NativeHTTP(origin: URL(string: ProcessInfo.processInfo.environment["DOCS_SMOKE_ORIGIN"]!)!),
- signOut: {}, documentID: "fixture")
+ signOut: {}, documentID: "fixture", newDocument: ProcessInfo.processInfo.environment["DOCS_SMOKE_DRAFT"] == "1")
}.defaultSize(width: 1120, height: 820)
.restorationBehavior(.disabled)
.commands { DocsEditorCommands(); DocsWindowCommands(windows: windows) }
@@ -28,6 +28,32 @@ private struct Launcher: View {
SmokeRun.started = true
NSApp.setActivationPolicy(.regular)
openWindow(id: "document", value: "fixture")
+ if ProcessInfo.processInfo.environment["DOCS_SMOKE_DRAFT"] == "1" {
+ let start = ContinuousClock.now
+ var draftEditor: NSTextView?
+ var draftWindow: NSWindow?
+ for _ in 0..<20 {
+ if let window = NSApp.windows.first(where: { $0.title == docsMacString("Untitled document") }), let editor = window.contentView.flatMap(findEditor) {
+ draftWindow = window; draftEditor = editor; break
+ }
+ try? await Task.sleep(for: .milliseconds(50))
+ }
+ guard let window = draftWindow, let editor = draftEditor else { fatalError("Local editor waited for server creation") }
+ precondition(start.duration(to: .now) < .seconds(1))
+ NSApp.activate(ignoringOtherApps: true)
+ window.makeKeyAndOrderFront(nil); window.makeFirstResponder(editor)
+ editor.insertText("Instant draft", replacementRange: NSRange(location: 0, length: 0))
+ for _ in 0..<80 {
+ if window.title == "Window smoke fixture" { break }
+ try? await Task.sleep(for: .milliseconds(50))
+ }
+ precondition(window.title == "Window smoke fixture")
+ precondition(window.contentView.flatMap(findEditor) === editor, "Server attachment replaced the editor")
+ precondition(editor.string.contains("Instant draft"), "Creation lost locally typed text")
+ print("PASS: draft editor appears before delayed creation and keeps its text view and typing")
+ await exercise(window, editor: editor)
+ return
+ }
for _ in 0..<100 {
if let window = NSApp.windows.first(where: { $0.title == "Window smoke fixture" }),
let editor = window.contentView.flatMap(findEditor), !editor.string.isEmpty {
@@ -77,6 +103,28 @@ private struct Launcher: View {
precondition(window.contentView.flatMap(findEditor) === editor, "Inspector recreated editor")
precondition(editor.string == original && editor.selectedRange() == NSRange(location: 1, length: 2), "Panel lost content or selection")
}
+ // Save while the title field is still active, without submitting Return.
+ if let menu = NSApp.mainMenu, let rename = menuItem("Rename document", in: menu) {
+ rename.menu?.delegate?.menuNeedsUpdate?(rename.menu!)
+ rename.menu?.update()
+ guard let currentRename = menuItem("Rename document", in: menu), currentRename.isEnabled else { fatalError("Rename disabled in writable document") }
+ currentRename.menu?.performActionForItem(at: currentRename.menu!.index(of: currentRename))
+ try? await Task.sleep(for: .milliseconds(200))
+ func findTitle(_ view: NSView) -> NSTextField? {
+ if let field = view as? NSTextField, field.isEditable { return field }
+ return view.subviews.lazy.compactMap(findTitle).first
+ }
+ guard let root = window.contentView?.superview, let field = findTitle(root) else { fatalError("Editable header title missing") }
+ field.selectText(nil)
+ guard let input = field.currentEditor() as? NSTextView else { fatalError("Title has no field editor") }
+ input.selectAll(nil); input.insertText("Renamed from header", replacementRange: input.selectedRange())
+ try? await Task.sleep(for: .milliseconds(200))
+ guard let save = menuItem(docsMacString("Save"), in: menu) else { fatalError("Save unavailable") }
+ save.menu?.performActionForItem(at: save.menu!.index(of: save))
+ try? await Task.sleep(for: .milliseconds(300))
+ precondition(window.title == "Renamed from header", "Save lost the active title input")
+ print("PASS: Save commits active header title without Return")
+ } else { fatalError("Rename command missing") }
window.makeFirstResponder(editor)
editor.setSelectedRange(NSRange(location: editor.string.utf16.count - 1, length: 0))
NSApp.activate(ignoringOtherApps: true)
@@ -97,7 +145,7 @@ private struct Launcher: View {
precondition(rect.height > 0, "Caret clipped outside editor bounds")
precondition(editor.bounds.maxY >= line.maxY, "Editor shrank below current line")
let visible = indicators(window.contentView!).filter { !$0.isHidden }
- precondition(visible.count == 1, "Expected exactly one visible native insertion indicator")
+ precondition(visible.count == 1, "Expected exactly one visible native insertion indicator; count=\(visible.count), phase=\(phase), editable=\(editor.isEditable), attached=\(editor.window === window), key=\(window.isKeyWindow), responder=\(String(describing: window.firstResponder))")
for indicator in visible {
let actual = indicator.convert(indicator.bounds, to: editor)
precondition(abs(actual.minY - line.minY) < 1, "Native insertion indicator disagrees with glyphs")