Skip to content

文档 ​

每个窗口显示一个文件的应用,例如文本编辑器、绘图应用或笔记应用,是基于文档的应用。mygo.NewDocuments 在所有平台上为它们提供 AppKit 的 NSDocument 和 SwiftUI 的 DocumentGroup 赋予 Mac 应用的能力:

  • 每个文档一个窗口,标题中显示其名称;在 macOS 上,标题栏中还有文件图标,有未保存更改时关闭按钮上有一个圆点;
  • File 菜单中的新建、打开、打开最近文档、保存、另存为、创建副本、重命名、移动到和复原;
  • 自动保存,并且只在无法自动保存的地方才询问是否保存;
  • 为每一处未保存的更改保留恢复副本,崩溃或退出都不会丢失内容;
  • 从 Finder、资源管理器、Dock 和跳转列表打开文档;
  • 在打开期间被其他应用修改的文件。

一个用原生 UI编写的笔记编辑器,整个应用:

go
func main() {
	mygo.NewDocuments(mygo.DocumentsOptions[string]{
		Types: []mygo.DocumentType{{Name: "Markdown Document", Extensions: []string{"md"}}},
		Read: func(r io.Reader) (string, error) {
			b, err := io.ReadAll(r)
			return string(b), err
		},
		Write: func(w io.Writer, text string) error {
			_, err := io.WriteString(w, text)
			return err
		},
		Equal: func(a, b string) bool { return a == b },
		Window: func(doc *mygo.Document[string]) *mygo.Window {
			text := doc.Content()
			var win *mygo.Window
			win = mygo.NewWindow(mygo.WindowOptions{Content: ui.View(func(c *ui.Context) {
				ui.TextArea(c, &text).Fill().OnChange(func() { doc.Set(text) })
			})})
			doc.OnReload(func() { text = doc.Content(); win.Update(func() {}) })
			return win
		},
	})
	if err := mygo.App.Run(); err != nil {
		log.Fatal(err)
	}
}

examples/documents 就是这个编辑器的完整版,带有状态栏以及处理其他应用修改文件的提示条:

sh
go run ./cmd/mygo dev examples/documents

文档及其内容 ​

DocumentsOptions[T] 描述一种文档,其内容为 T:

字段
Types文件的种类,由名称和扩展名描述,例如 {Name: "Markdown Document", Extensions: []string{"md", "markdown"}}。打开对话框会显示它们;新文档以第一个扩展名保存
Read在 goroutine 上从文件读取内容
Write在 goroutine 上把内容写入文件
Window在主线程上为文档创建窗口
New新文档的内容;未提供时使用 T 的零值
Equal比较内容,使得撤销回到文件当前内容后文档恢复为未编辑状态
Snapshot在写入之前复制可变内容
AutosaveAutosaveInPlace(默认)或 AutosaveOff
AutosaveDelay最后一次编辑后多久进行自动保存:5 秒
AskOnQuit退出时询问是否保存,而不是把文档保留到下次启动
NoUntitled应用启动时没有可打开的内容,也不创建未命名文档

请在 App.Run 之前调用 NewDocuments。应用启动时,会打开随应用启动而传入的文件、崩溃或退出时留有未保存更改的文档,否则新建一个未命名文档。一个应用可以创建多种文档;第一种就是 File > New 新建的文档。

Document[T] 是一个已打开的文档。它的方法可以从任意 goroutine 安全调用:

go
doc.Content()   // 内容
doc.Set(v)      // 替换内容,即编辑文档
doc.State()     // 名称、路径、是否已编辑、只读、冲突、已删除
doc.Window()    // 它的窗口
doc.Save()      // 相当于 File > Save

值与可变内容 ​

内容最好是应用用 Set 替换的值:字符串、由值组成的结构体,或持久化快照,例如 ui.TextSnapshot 或 editor.Snapshot,它们共享未改变的部分。保存时,在 goroutine 上写入保存开始时的内容,用户可同时继续编辑,因此不被任何代码修改的值本身就是快照。

可变内容,例如应用就地修改的模型指针,需要提供 Snapshot,它在应用修改内容的主线程上复制内容,并用 Changed 报告变更:

go
mygo.DocumentsOptions[*Drawing]{
	Snapshot: func(d *Drawing) *Drawing { return d.Clone() },
	// …
}

drawing.Shapes = append(drawing.Shapes, shape)
doc.Changed()

已编辑状态 ​

当文档的内容与其文件不同时,文档处于已编辑状态:

  • Set 和 Changed 会将其标记为已编辑。提供 Equal 后,用 Set 设置成文件当前的内容会使其恢复未编辑状态——撤销回到文件内容,或输入一个字符再删除,正是如此:文本控件的撤销开箱即用。
  • SetEdited 可直接设置该状态,适用于更有数的应用,例如自行维护撤销历史的应用:设为 false 表示内容与文件一致,但不写入文件。
  • 保存会使其变为未编辑,除非内容在写入期间又发生了变化:这些改动仍未保存。

OnStateChange 在主线程上通知状态变化。

窗口 ​

文档的窗口显示其名称:文件的名称,或按应用显示的语言显示"未命名"、"未命名 2"……。文档窗口的标题在 macOS 上就是文档名称,在 Linux 和 Windows 上则是名称加上应用名,如同记事本和 gedit 的显示方式。页面的 <title> 不会改变它。

文档有未保存更改时,macOS 在窗口关闭按钮上显示一个圆点,Linux 和 Windows 在标题前显示 *。在 macOS 上,标题栏还显示文件图标,用户可以把它拖到另一个应用;Command 点击标题会列出文件所在的各级文件夹。

任何应用的窗口都可以通过 Window.SetRepresentedFile 和 Window.SetDocumentEdited 实现同样的显示。Window.Title 返回不含 * 的标题。

保存 ​

Save()把文档写入其文件。未命名文档会询问保存位置,只读文档会索取另一个文件,文件被其他应用修改过的文档会询问是否覆盖。ErrCanceled 表示用户取消了操作
SaveAs(path)把它写入另一个文件,文档此后即为该文件;传 "" 则询问位置
SaveAnyway()即使其他应用修改了其文件也照常写入
Duplicate()打开一个未命名副本,例如名为"Notes copy"
Rename(name)在其所在文件夹中重命名文件;传 "" 则询问
MoveTo(dir)把文件移动到另一个文件夹;传 "" 则询问
Revert()重新读取文件,丢弃未保存的更改
Close()关闭其窗口,必要时先询问是否保存

文件的写入方式与 NSDocument 和 GIO 相同:先写入文档旁边的新文件,刷入磁盘后再替换原文件,因此崩溃或磁盘写满绝不会留下半个文件。文件保留其元数据:macOS 上通过 NSFileManager,保留权限、创建日期、Finder 标签及其他扩展属性、ACL 以及文件身份标识,因此替身(alias)仍能指向它;Windows 上通过 ReplaceFileW,保留安全描述符、属性、备用数据流和创建时间,并为被其他程序占用的文件稍作等待;Linux 上保留权限、所有者和组。符号链接会替换其目标;有其他硬链接的文件,或位于用户无法创建文件的文件夹中的文件,则就地写入。当 ReplaceFileW 在删除原文件后中途失败时,新文件会被移到原位置;若连这一步也失败,新文件会留在文档原位置旁边,保存的错误信息会指明它。

写入之前,会把文件与文档上次读取或写入时的状态(修改时间和大小)进行比较:当其他应用修改了它时,Save 会询问,自动保存则什么都不写。

自动保存 ​

默认情况下(AutosaveInPlace),文档会在用户停止编辑片刻后(AutosaveDelay,用户持续编辑时至少每 30 秒一次)、窗口失去焦点时以及关闭时保存到其文件,与 Mac 应用一致。已保存的文档从不询问是否保存。

无法就地保存的文档不会自动保存:未命名文档,文件为只读、已被删除或被其他应用修改过的文档。它们会在关闭时询问。

使用 AutosaveOff 时,文档只在用户保存时保存,并在关闭时询问,如同 Windows 和 Linux 应用的传统做法。

恢复副本 ​

每个有未保存更改且自动保存不写入的文档都会保留一份恢复副本,在用户停止编辑一秒后创建,如同 VS Code 的备份和 Zed 的数据库所做的那样:未命名文档、无自动保存应用的文档、处于冲突状态的文档。副本位于 PathUserData 下的 Recovery/<kind> 中,由 Write 写入,并记录文件的修改时间和大小;当文档被保存、复原或关闭且不保存时,副本随之删除。

应用启动时,崩溃或强制退出留下的文档会恢复回来,并标记为已编辑。其间文件被修改过的文档处于冲突状态。应用的每个运行实例都在自己的会话中保存副本,运行期间加锁,因此其他实例绝不会打开它们。

关闭与退出 ​

询问
关闭未编辑的文档不询问
带自动保存时,关闭已保存过的已编辑文档不询问:会先保存
带自动保存时,关闭已编辑的未命名文档"要保留这个新文档"未命名"吗?"保存……、删除、取消
关闭无自动保存的已编辑文档,或无法就地保存的文档"要保存对文档"Notes.md"所做的更改吗?"保存、不保存、取消
退出不询问:文档就地保存或保留,下次启动时以已编辑状态恢复
使用 AskOnQuit 退出,且有多份文档macOS:"你有 3 份文档存在未保存的更改。要在退出前查看这些更改吗?"查看更改……、放弃更改、取消,然后每份文档各一个表单。Linux 和 Windows:一个列出它们名称的对话框,全部保存、不保存、取消

这些提问、它们的按钮以及未命名文档的名称都是 MyGo 自身的字符串,以应用显示的语言呈现。在 macOS 上,提问以文档窗口的表单(sheet)显示。需要询问的关闭会让窗口保持打开,直到用户回答;退出流程会等待文档处理完毕再退出,选择取消则所有窗口都保持打开。此时退出的 OnBeforeQuit 监听器会再次运行。

未经询问就被销毁的窗口(Window.Destroy、App.Exit),其文档未保存更改的恢复副本会保留,并在下次启动时恢复。

被其他应用修改的文件 ​

文档会监视自己的文件(见监视文件),并在窗口获得焦点时检查文件,如同 VS Code 和 Notepad++ 的做法,因为监视可能漏掉更改:

  • 没有未保存更改的文档会重新读取其文件,OnReload 通知应用显示新内容;
  • 有未保存更改的文档进入冲突状态:其状态会标明这一点,保存时询问保留自己的版本还是其他应用的版本("仍要保存"、"复原");
  • 其他应用在其所在文件夹中重命名文件时,文档会跟随新文件;
  • 文件被删除后,文档保持打开,其状态中 Deleted 为真,保存时会重新写入文件。

File 菜单 ​

文档相关的菜单角色作用于获得焦点窗口的文档,并针对它启用:

角色macOSLinux、Windows
RoleNew新建 ⌘N新建 Ctrl+N
RoleOpen打开… ⌘O打开… Ctrl+O
RoleOpenRecent打开最近文档打开最近文档
RoleSave保存 ⌘S保存 Ctrl+S
RoleSaveAs另存为… ⌥⇧⌘S另存为… Ctrl+Shift+S
RoleDuplicate创建副本 ⇧⌘S创建副本
RoleRename重命名…重命名…
RoleMoveTo移动到…移动到…
RoleRevert复原到已保存版本复原

当应用有文档时,RoleFileMenu 包含各平台的 File 菜单:macOS 上是 DocumentGroup 的样式(新建、打开、打开最近文档、关闭、保存、创建副本、重命名、移动到、复原到已保存版本),其他平台是记事本和 KDE 的样式(新建、打开、打开最近文档、保存、另存为、复原、关闭、退出)。有文档的应用在 Linux 和 Windows 上也会获得菜单栏,包含 File 和 Edit 菜单,直到它们设置自己的菜单。

打开最近文档列出应用最近打开和保存的文档,最新的在前,即 App.RecentDocuments 返回的内容:文档用 App.AddRecentDocument 记录其文件,同时也会记录到系统中(macOS 上的 Dock 菜单、Windows 上的跳转列表、Linux 上最近使用的文件),其"清除菜单"项调用 App.ClearRecentDocuments。见最近使用的文档。

打开文件 ​

文档会打开系统要求应用打开的(App.OnOpenFile)、属于其扩展名的文件:来自 Finder 或资源管理器、Dock 或跳转列表,或命令行。请在配置的 fileAssociations 中列出这些类型,系统便会为这些文件提供该应用:

json
"fileAssociations": [
  { "ext": ["md", "markdown"], "name": "Markdown Document", "mimeType": "text/markdown" }
]

在 Linux 和 Windows 上,每次打开文件都会重新启动应用:请调用 App.RequestSingleInstanceLock,让第一个实例来打开它们。

已经打开的文件会聚焦其所在窗口。在 macOS 上,应用没有窗口时仍保持运行,如同文档应用的传统,点击其 Dock 图标会新建一个未命名文档。

Web 页面 ​

文档窗口的页面通过生成的 TypeScript 客户端获取其文档:把文档服务以客户端调用它所用的名称绑定,

go
notes := mygo.NewDocuments(mygo.DocumentsOptions[Note]{ /* … */ })
mygo.BindAs("Note", notes.Service())

页面便可读取并替换其窗口文档的内容,并跟踪其状态:

ts
import { Note } from "./mygo"; // generated
import { onDocumentReload, onDocumentState } from "mygo-runtime";

editor.value = (await Note.content()).body;
editor.oninput = () => Note.setContent({ body: editor.value });
onDocumentState(({ name, edited }) => { status.textContent = edited ? "Edited" : "Saved" });
onDocumentReload(async () => { editor.value = (await Note.content()).body });

该服务提供 content、setContent、state、save、saveAs、duplicate、rename、moveTo 和 revert,作用于调用它们的窗口。页面在每次按键时发送全部内容,适合笔记和小文档;大文档则应在用户停顿时发送。

平台差异 ​

macOSLinuxWindows
已编辑标记关闭按钮上的圆点标题前的 *标题前的 *
标题栏中的文件代理图标,Command 点击显示其所在文件夹无无
标题名称名称 - 应用名名称 - 应用名
替换文件NSFileManager 的 replaceItemAtURL:重命名,保留模式、所有者和组ReplaceFileW
提问窗口的表单(sheet)对话框对话框
无窗口时应用保持运行;Dock 图标可新建文档应用退出应用退出

未包含的功能 ​

以下功能只有 macOS 上真正的 NSDocument 才具备,暂未包含:在标题栏的弹出面板中重命名、标题旁的"— 已编辑"、浏览版本及还原到某个版本、锁定文件,以及 iCloud 草稿。以文件夹形式存在的文档(包)尚不支持,浏览器也没有可打开的文件。