Open editable local drafts immediately and rename document titles
45 files changed,
+663
−55
internal/docs/app.go+1 −0internal/docs/handlers.go+54 −1internal/docs/migrations/0008_document_creation_token.sql+2 −0internal/docs/models.go+2 −1internal/docs/native_test.go+51 −0internal/docs/store.go+37 −0native/INSTANT-DOCUMENTS.md+28 −0native/apple/Sources/DocsApp/DocsAccountWindow.swift+4 −0native/apple/Sources/DocsApp/DocsDocumentWindow.swift+1 −1native/apple/Sources/DocsApp/DocsWindowCommands.swift+7 −2native/apple/Sources/DocsApp/DocsWindows.swift+17 −2native/apple/Sources/DocsKit/DocsDocumentMenu.swift+4 −2native/apple/Sources/DocsKit/DocsEditorContext.swift+1 −0native/apple/Sources/DocsKit/DocsModel.swift+108 −19native/apple/Sources/DocsKit/DocsWorkspace.swift+62 −22native/apple/Sources/DocsKit/Resources/ar.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/ar.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/bn.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/bn.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/en.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/en.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/es.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/es.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/ga.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/ga.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/hi.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/hi.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/ja.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/ja.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/pt.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/pt.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/ru.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/ru.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/vi.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/vi.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/yue.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/yue.lproj/Mac.strings+2 −0native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Localizable.strings+2 −0native/apple/Sources/DocsKit/Resources/zh-Hans.lproj/Mac.strings+2 −0native/apple/Tests/DocsKitTests/DocumentDraftTests.swift+72 −0native/apple/Tests/DocsWindowTests/WindowRoutingTests.swift+17 −0native/scripts/test-document-drafts.py+77 −0native/scripts/test-document-window.py+10 −3native/smoke/document-window/README.md+10 −0native/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") |