dsh-markdown-typora 0.3.5 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-markdown-typora
2
2
 
3
- **A Markdown editor for the DeepSeek Harness right sidebar, built on MarkPilot CM6 3.6.9.**
3
+ **A Markdown editor for the DeepSeek Harness right sidebar, built on MarkPilot CM6 3.6.16.**
4
4
 
5
5
  Write Markdown in DSH with Typora-style live rendering; select a passage and send its **Markdown source** straight into the current conversation.
6
6
 
@@ -29,6 +29,7 @@ Write Markdown in DSH with Typora-style live rendering; select a passage and sen
29
29
  - **Real editor, not a preview** — CodeMirror 6 with live headings, lists, tables, inline math, code blocks and Mermaid.
30
30
  - **Selection → chat** — a floating **Insert into chat** button appears beside a selection; `Ctrl/Cmd + Shift + Enter` does the same. The payload starts with the document's absolute path, then a blank line and the Markdown source (not the rendered text), inserted at the composer caret without stealing focus.
31
31
  - **Guarded saves** — debounced autosave with version checks; `Ctrl/Cmd + S` saves immediately with a visible result, whether the caret is in the editor or elsewhere in the panel. An external edit is never overwritten. A dirty buffer is kept in a recovery slot when the tab closes.
32
+ - **Shared notes across windows** — the same file can be edited in the MarkPilot desktop app and the DSH sidebar at once. Unsaved changes are exchanged live, each window keeps its own undo history, and a genuine conflict is reported with the draft preserved.
32
33
  - **Save files outside the session workspace** — a file opened from the sidebar saves directly to its own folder even when it sits outside the workspace root of the current session; version checks and conflict warnings are unchanged. Files that are read-only or locked at the OS level still report an error.
33
34
  - **Theme-aware** — follows the host's light/dark theme, including the first frame.
34
35
  - **Offline assets** — KaTeX fonts, the Markdown worker and Mermaid ship with the plugin; no CDN.
@@ -36,15 +37,16 @@ Write Markdown in DSH with Typora-style live rendering; select a passage and sen
36
37
  - **Local images in the document** — `![](assets/diagram.png)` and parent-folder references such as `![](../figures/a.png)` render in the panel, read through the plugin's own read-only image route; a file that is genuinely missing still shows an explicit placeholder instead of an empty box.
37
38
  - **Edit right beside an image** — a click in the blank to the right of an image starts editing on the following line (headings, lists and task items keep their markers), instead of opening the image's Markdown source; clicking the image itself still opens its source.
38
39
 
39
- ## What's new in the 3.6.9 engine
40
+ ## What's new in the 3.6.16 engine
40
41
 
41
- The editor engine is the same accepted build that ships with MarkPilot 3.6.9:
42
+ The editor engine is the same accepted build that ships with MarkPilot 3.6.16. Since the 0.3.4 plugin, the right sidebar gained the engine's multi-window work and the save-path fixes that came with it:
42
43
 
43
- - **Image right-side clicks** — the blank to the right of an image edits the following line; the image itself still opens its source, and a click in the editor's right gutter no longer drops the caret.
44
- - **Source view for long documents** — documents that carry embedded images can now show the rendered page and editable source side by side.
45
- - **Embedded-image protection** — body text and the source view keep image payloads collapsed, and an image can be backed up to an `assets` folder next to the document before it is removed.
46
- - **Table editing** — Chinese IME composition, escaped pipes, formatting commands and undo/redo stay in sync with the cell source.
47
- - **Formula selections** — a selection that crosses an inline formula keeps the whole formula, so copying and continued editing stay predictable.
44
+ - **Shared editing across windows** — the same note can be open in the desktop app and the DSH sidebar. Unsaved edits are exchanged live, each window undoes only its own typing, and a real conflict is shown with the draft kept instead of overwriting the other window.
45
+ - **Save while typing** — a save in flight is re-synced to the latest text; an external writer is detected and reported; the recovery banner's **Keep my changes** now actually writes the shared draft; and a cut → undo → redo followed by Ctrl+S no longer fails as an invalid collaborative edit.
46
+ - **Reverse selections** — dragging from a rendered formula across code, images or into the previous paragraph keeps the selection under the pointer, including auto-scroll at the top edge.
47
+ - **Table and rich-paste editing** — cell commands, End/Backspace, escaped pipes, Chinese IME composition, and pasted HTML tables keep captions, source links, inline images and alignment.
48
+ - **Cross-note cut** — real images and local Markdown links are re-based when a passage moves between lecture and summary notes; included definitions are renamed without breaking existing references.
49
+ - **Plugin manager reload** — notes opened after a plugin reload still join the live session, and repeated reloads reuse the background writer instead of leaking workers.
48
50
 
49
51
  ## Install
50
52
 
@@ -59,15 +61,14 @@ Or open **Settings → Plugins → Add plugin** and enter `dsh-markdown-typora`.
59
61
 
60
62
  ### From a GitHub release
61
63
 
62
- Download `dsh-markdown-typora-0.3.4.tgz` from the [MarkPilot releases](https://github.com/ltmroberthk915/markpilot/releases) and add it to the profile that runs your host:
64
+ Download `dsh-markdown-typora-0.3.11.tgz` from the [MarkPilot releases](https://github.com/ltmroberthk915/markpilot/releases) and add it to the profile that runs your host:
63
65
 
64
66
  ```sh
65
- dsh plugin --profile web add ./dsh-markdown-typora-0.3.4.tgz
66
- dsh plugin --profile desktop add ./dsh-markdown-typora-0.3.4.tgz
67
+ dsh plugin --profile web add ./dsh-markdown-typora-0.3.11.tgz
68
+ dsh plugin --profile desktop add ./dsh-markdown-typora-0.3.11.tgz
67
69
  ```
68
70
 
69
- Use this only when you cannot install from npm; the package on npm tracks the same source.
70
-
71
+ When the npm registry lists 0.3.11, installing by package name above gives this build; if the registry copy still lags, use the TGZ above to get exactly 0.3.11.
71
72
  ### Manual (development checkout)
72
73
 
73
74
  The plugin is the `dsh-markdown-typora/` subdirectory of the [MarkPilot repository](https://github.com/ltmroberthk915/markpilot). To load a checkout as a bundle:
@@ -87,7 +88,7 @@ Then add a junction/symlink from the profile's `node_modules` to `markpilot/dsh-
87
88
 
88
89
  ## How it is built
89
90
 
90
- The editor engine is the already-built MarkPilot CM6 3.6.6 renderer, copied byte-for-byte into `renderer-cm6/cm6/`. The plugin adds only:
91
+ The editor engine is the already-built MarkPilot CM6 3.6.16 renderer, copied byte-for-byte into `renderer-cm6/cm6/`. The plugin adds only:
91
92
 
92
93
  - `renderer-cm6/index.html` + `dsh-bridge.js` + `dsh-selection.js` — the DSH skin and selection bridge;
93
94
  - `lib/index.js` — static routes, version-guarded file API, SSE change stream;
package/core.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "core": "MarkPilot CM6",
3
- "version": "3.6.9",
3
+ "version": "3.6.16",
4
4
  "source": "../MarkPilot-CM6/renderer/cm6",
5
- "syncedAt": "2026-10-31T04:52:31.281Z",
5
+ "syncedAt": "2026-11-04T03:08:27.900Z",
6
6
  "generator": "scripts/sync-core.mjs",
7
7
  "files": [
8
8
  {
@@ -312,8 +312,8 @@
312
312
  },
313
313
  {
314
314
  "path": "app.js",
315
- "sha256": "b06de411debe28862547239a9a9ed9d0fa27cabc804dd05d63c1f6ed2cc93e6d",
316
- "bytes": 1485205
315
+ "sha256": "2dcff06daa8522a3d53a7cea642deed486d200394466ad58ecb4e682e9c29dcc",
316
+ "bytes": 1536788
317
317
  },
318
318
  {
319
319
  "path": "fonts/KaTeX_AMS-Regular-CYEKBG2K.woff",
@@ -626,5 +626,5 @@
626
626
  "bytes": 3572661
627
627
  }
628
628
  ],
629
- "coreHash": "9150b5bb82c3384a427fd723f7f464a6c45ccbdf26df799a398ba69b7be6cdb4"
629
+ "coreHash": "8d51c9df5a97992b1d309c54f88fda5ebc3e8f4b583a37d2b1e4ecee700a4d9a"
630
630
  }
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+ // Document metadata belongs to one editor session. Only disk operations are shared.
3
+ const fs=require('node:fs/promises');
4
+ const path=require('node:path');
5
+ const crypto=require('node:crypto');
6
+ const {decodeDocument,encodeDocument}=require('./text-codec');
7
+ const {prepareFile}=require('./safe-file');
8
+ // Local saves share the cross-process file-write reservation with the live
9
+ // rooms authority (EXE windows, DSH hosts, model writes). LiveDocuments only
10
+ // pulls this module lazily, so the require cycle stays load-time safe.
11
+ const {withFileCommit, defaultDirectory: liveCommitDirectory}=require('./live-documents');
12
+ const failure=(code,message)=>Object.assign(new Error(message),{code});
13
+ const digest=bytes=>crypto.createHash('sha256').update(bytes).digest('hex');
14
+ function createSession(){
15
+ return {documentId:crypto.randomUUID(),revision:0,lastSavedRevision:0,filePath:null,savedText:'',diskMtime:0,baseline:null,bom:false,eol:'\n',encoding:'utf8',dirty:false,saveAsRequest:0,saveAsGrant:null,lastSave:null,loadRequest:0,pendingSaves:new Set()};
16
+ }
17
+ class DocumentStore{
18
+ constructor({beforeReplace=async()=>{},prepareSaveAs=async()=>null,commitDirectory=liveCommitDirectory()}={}){
19
+ this.jobs=new Map();this.commitTail=Promise.resolve();this.beforeReplace=beforeReplace;this.prepareSaveAs=prepareSaveAs;
20
+ // Must match the LiveDocuments directory for same-lock reentrancy when a
21
+ // live save hands its writer down to this store's compare+replace commit.
22
+ this.commitDirectory=commitDirectory;
23
+ }
24
+ reset(S){const pending=S.pendingSaves,request=S.loadRequest+1;Object.assign(S,createSession(),{pendingSaves:pending,loadRequest:request});return this.metadata(S);}
25
+ metadata(S){return {documentId:S.documentId,revision:S.revision,filePath:S.filePath,text:S.savedText,bom:S.bom,eol:S.eol,encoding:S.encoding};}
26
+ async canonical(file){
27
+ const absolute=path.resolve(file);
28
+ try{return await fs.realpath(absolute);}catch(e){if(e.code!=='ENOENT')throw e;}
29
+ let parent=path.dirname(absolute),tail=[path.basename(absolute)];
30
+ for(;;){try{return path.join(await fs.realpath(parent),...tail);}catch(e){if(e.code!=='ENOENT')throw e;const next=path.dirname(parent);if(next===parent)return absolute;tail.unshift(path.basename(parent));parent=next;}}
31
+ }
32
+ key(file){return process.platform==='win32'?file.toLowerCase():file;}
33
+ queue(key,fn){
34
+ const previous=this.jobs.get(key)||Promise.resolve(),job=previous.catch(()=>{}).then(fn);
35
+ this.jobs.set(key,job);const clear=()=>{if(this.jobs.get(key)===job)this.jobs.delete(key);};job.then(clear,clear);return job;
36
+ }
37
+ commit(fn){const job=this.commitTail.catch(()=>{}).then(fn);this.commitTail=job;return job;}
38
+ async snapshot(file,{allowMissing=false}={}){
39
+ let handle;
40
+ try{
41
+ handle=await fs.open(file,'r');const first=await handle.stat();
42
+ if(!first.isFile())throw failure('MP_NOT_FILE','目标不是普通文件:'+file);
43
+ const bytes=await handle.readFile(),last=await handle.stat(),onPath=await fs.stat(file);
44
+ if(first.mtimeMs!==last.mtimeMs||first.size!==last.size||last.ino!==onPath.ino||last.dev!==onPath.dev||last.mtimeMs!==onPath.mtimeMs||last.size!==onPath.size)
45
+ throw failure('MP_FILE_CONFLICT','读取期间文件已被其他程序修改,请重新加载:'+file);
46
+ return {bytes,baseline:{sha256:digest(bytes),mtimeMs:last.mtimeMs,size:bytes.length,nlink:last.nlink}};
47
+ }catch(e){if(allowMissing&&e.code==='ENOENT')return null;throw e;}finally{await handle?.close();}
48
+ }
49
+ async load(S,file,expected=null){
50
+ if(typeof file!=='string'||!file)throw failure('MP_BAD_PATH','没有指定文件路径');
51
+ const origin=expected||{documentId:S.documentId,revision:S.revision,filePath:S.filePath};
52
+ const current=()=>S.documentId===origin.documentId&&S.revision===origin.revision&&this.key(S.filePath||'')===this.key(origin.filePath||'');
53
+ if(!current()||(expected&&expected.loadRequest!==S.loadRequest))throw failure('MP_DOC_MISMATCH','重载期间文档或编辑版本已改变');
54
+ const request=++S.loadRequest,canonical=await this.canonical(file);
55
+ if(expected&&this.key(canonical)!==this.key(expected.filePath))throw failure('MP_FILE_CONFLICT','原文件位置已改变,请另存为保留当前内容');
56
+ return this.queue(this.key(canonical),async()=>{
57
+ const snap=await this.snapshot(canonical),decoded=decodeDocument(snap.bytes,file);
58
+ if(this.key(await this.canonical(file))!==this.key(canonical))throw failure('MP_FILE_CONFLICT','读取期间文件位置已改变,已保留原文档:'+file);
59
+ if(request!==S.loadRequest||!current())throw failure('MP_DOC_MISMATCH','文档已切换或继续编辑,本次读取已取消');
60
+ const pending=S.pendingSaves,{text,...format}=decoded;
61
+ Object.assign(S,createSession(),format,{pendingSaves:pending,loadRequest:request,filePath:canonical,savedText:text,baseline:snap.baseline,diskMtime:snap.baseline.mtimeMs});
62
+ return this.metadata(S);
63
+ });
64
+ }
65
+ markDirty(S,{documentId,revision,dirty}){
66
+ if(documentId!==S.documentId||!Number.isSafeInteger(revision)||revision<S.revision)return false;
67
+ S.revision=revision;
68
+ S.dirty=!!dirty||revision!==S.lastSavedRevision;
69
+ return true;
70
+ }
71
+ beginSaveAs(S){
72
+ S.saveAsGrant=null;
73
+ return {documentId:S.documentId,serial:++S.saveAsRequest};
74
+ }
75
+ async grantSaveAs(S,file,request=this.beginSaveAs(S)){
76
+ const current=()=>request.documentId===S.documentId&&request.serial===S.saveAsRequest;
77
+ if(!current())throw failure('MP_SAVE_TARGET','另存为操作已被新的选择替代');
78
+ const documentId=request.documentId,canonical=await this.canonical(file),snap=await this.snapshot(canonical,{allowMissing:true});
79
+ if(!current())throw failure('MP_SAVE_TARGET','另存为操作已被新的选择替代');
80
+ const grant={token:crypto.randomUUID(),documentId,request:request.serial,filePath:path.resolve(file),canonical,baseline:snap?.baseline||null};
81
+ S.saveAsGrant=grant;return {ok:true,filePath:grant.filePath,documentId,saveAsToken:grant.token};
82
+ }
83
+ async grantOverwrite(S,expected,ownerCurrent=()=>true){
84
+ const current=()=>ownerCurrent()&&expected?.documentId===S.documentId&&expected.revision===S.revision&&expected.filePath===S.filePath;
85
+ if(!current()||!S.filePath)throw failure('MP_DOC_MISMATCH','文档或编辑版本已改变,覆盖已取消');
86
+ const grant=await this.grantSaveAs(S,S.filePath);
87
+ if(S.saveAsGrant?.token!==grant.saveAsToken)throw failure('MP_SAVE_TARGET','覆盖操作已被新的选择替代');
88
+ if(!current()||!S.saveAsGrant?.baseline){
89
+ if(S.saveAsGrant?.token===grant.saveAsToken)S.saveAsGrant=null;
90
+ throw failure(current()?'MP_FILE_MISSING':'MP_DOC_MISMATCH','文件已移除或编辑版本已改变,请另存为保留修改');
91
+ }
92
+ if(this.key(S.saveAsGrant.canonical)!==this.key(expected.filePath)){
93
+ S.saveAsGrant=null;
94
+ throw failure('MP_FILE_CONFLICT','原文件位置已改变,请另存为保留修改');
95
+ }
96
+ S.saveAsGrant.overwriteRevision=expected.revision;
97
+ return grant;
98
+ }
99
+ async validate(file,baseline){
100
+ const actual=await this.snapshot(file,{allowMissing:true});
101
+ if(baseline&&!actual)throw failure('MP_FILE_MISSING','原文件已不在磁盘上(可能被重命名或删除):'+file);
102
+ if((!baseline&&actual)||(baseline&&actual?.baseline.sha256!==baseline.sha256))throw failure('MP_FILE_CONFLICT','文件已被其他窗口或程序修改,请重新加载或另存为:'+file);
103
+ if(actual?.baseline.nlink>1)throw failure('MP_FILE_LINKED','文件包含硬链接,请另存为独立文件后编辑:'+file);
104
+ }
105
+ save(S,request={}){
106
+ const job=(async()=>{
107
+ const {filePath,text,documentId,revision,saveAsToken}=request;
108
+ if(documentId!==S.documentId||typeof text!=='string'||!Number.isSafeInteger(revision)||revision<0)throw failure('MP_DOC_MISMATCH','文档或编辑版本已改变,保存已取消');
109
+ const sourcePath=S.filePath,ownerCurrent=typeof request.ownerCurrent==='function'?request.ownerCurrent:()=>true;
110
+ const grant=saveAsToken&&S.saveAsGrant;
111
+ const grantCurrent=()=>!grant||(S.documentId===grant.documentId&&S.saveAsRequest===grant.request);
112
+ if(saveAsToken&&(!grant||grant.token!==saveAsToken||grant.documentId!==documentId||grant.filePath!==path.resolve(filePath)))throw failure('MP_SAVE_TARGET','另存为许可已失效,请重新选择路径');
113
+ if(grant?.overwriteRevision!==undefined&&(revision!==grant.overwriteRevision||S.revision!==grant.overwriteRevision))throw failure('MP_DOC_MISMATCH','覆盖授权后继续编辑,请重新确认保存');
114
+ if(!grant&&(!S.filePath||path.resolve(filePath)!==S.filePath))throw failure('MP_SAVE_TARGET','保存目标不属于当前文档,请使用另存为');
115
+ if(grant)S.saveAsGrant=null; // One use, including a failed attempt.
116
+ const target=await this.canonical(filePath);
117
+ // Ordinary saves remain bound to the physical path captured at load.
118
+ // Only an explicit Save As may authorize a different target.
119
+ if(this.key(target)!==this.key(grant ? grant.canonical : sourcePath))throw failure('MP_FILE_CONFLICT','保存位置已改变,请重新选择路径');
120
+ return this.queue(this.key(target),async()=>{
121
+ // The whole local compare+atomic-replacement runs inside the same
122
+ // cross-process file-write reservation as LiveDocuments.save/external.
123
+ // A live-scoped writer reenters the inherited reservation instead of
124
+ // taking a second lease; standalone saves take the lease themselves,
125
+ // so two hosts on one baseline serialize and the later one loses only
126
+ // by the raw-baseline comparison, never by a torn interleave.
127
+ return withFileCommit(target,async()=>{
128
+ if(!grantCurrent())throw failure('MP_SAVE_TARGET','另存为操作已被新的选择替代');
129
+ if(S.documentId!==documentId||(!grant&&S.filePath!==path.resolve(filePath))||revision<S.lastSavedRevision)throw failure('MP_DOC_MISMATCH','文档已切换或已有更新的保存');
130
+ const baseline=grant?grant.baseline:S.baseline,format={bom:S.bom,eol:S.eol,encoding:S.encoding},out=encodeDocument(text,format);
131
+ await this.validate(target,baseline);
132
+ await fs.mkdir(path.dirname(target),{recursive:true});
133
+ const saveAsCurrent=()=>S.documentId===documentId&&grantCurrent()&&revision>=S.lastSavedRevision&&ownerCurrent();
134
+ const prepared=grant?await this.prepareSaveAs({sourcePath,filePath:target,text,canonical:f=>this.canonical(f),current:saveAsCurrent}):null;
135
+ const assets=prepared?.summary||prepared;
136
+ if(grant&&!saveAsCurrent())throw failure('MP_DOC_MISMATCH','另存为的文档或窗口已改变');
137
+ // A valid target may already use the filesystem's entire name budget.
138
+ // Keep the sibling staging/backup components independent of its basename.
139
+ const tmp=path.join(path.dirname(target),'.mp-tmp-'+process.pid+'-'+crypto.randomUUID());
140
+ let stage,primaryFailure;
141
+ try{
142
+ const outHash=digest(out);
143
+ stage=await prepareFile(target,tmp,{baseline,output:{bytes:out.length,sha256:outHash}});
144
+ await fs.writeFile(tmp,out,{flag:'r+'});
145
+ await this.beforeReplace({session:S,filePath:target,text,revision});
146
+ return await this.commit(async()=>{
147
+ // The short shared commit also protects alternate names of the same file.
148
+ if(typeof prepared?.validate==='function')await prepared.validate();
149
+ await this.validate(target,baseline);
150
+ if(this.key(await this.canonical(filePath))!==this.key(target))throw failure('MP_FILE_CONFLICT','保存位置已改变,请重新加载或另存为');
151
+ if(!grantCurrent())throw failure('MP_SAVE_TARGET','另存为操作已被新的选择替代');
152
+ if(grant&&!saveAsCurrent())throw failure('MP_DOC_MISMATCH','另存为的文档或窗口已改变');
153
+ if(grant?.overwriteRevision!==undefined&&S.revision!==grant.overwriteRevision)throw failure('MP_FILE_CONFLICT','覆盖确认后又有新输入,请重新确认保留修改');
154
+ const receipt=await stage.commit();const st=await fs.stat(target),id=crypto.randomUUID();
155
+ const current=S.documentId===documentId&&grantCurrent();
156
+ if(current){
157
+ S.filePath=target;S.savedText=text;S.baseline={sha256:outHash,mtimeMs:st.mtimeMs,size:out.length,nlink:st.nlink};S.diskMtime=st.mtimeMs;
158
+ S.lastSavedRevision=revision;S.dirty=S.revision!==revision;
159
+ S.lastSave={id,documentId,revision,filePath:S.filePath};
160
+ }
161
+ return {ok:true,saveId:id,documentId,revision,filePath:target,current,mtimeMs:st.mtimeMs,bytes:out.length,...(receipt?.backupRetained?{recoveryBackup:receipt.backupRetained}:{}),...(assets?{assets}:{})};
162
+ });
163
+ }catch(error){primaryFailure=error;throw error;}
164
+ finally{try{await stage?.abort();}catch(error){if(primaryFailure)primaryFailure.cleanupError=error.message;else throw error;}}
165
+ },this.commitDirectory);
166
+ });
167
+ })();
168
+ S.pendingSaves.add(job);const clear=()=>S.pendingSaves.delete(job);job.then(clear,clear);return job;
169
+ }
170
+ async pending(S){await Promise.allSettled([...S.pendingSaves]);}
171
+ async outsideChange(S,file){
172
+ if(file!==S.filePath)return {changed:false,stale:true};
173
+ const documentId=S.documentId,canonical=await this.canonical(file);
174
+ if(documentId!==S.documentId||file!==S.filePath)return {changed:false,stale:true};
175
+ if(this.key(canonical)!==this.key(file))return {changed:true,relocated:true};
176
+ const snap=await this.snapshot(canonical,{allowMissing:true});
177
+ if(documentId!==S.documentId||file!==S.filePath)return {changed:false,stale:true};
178
+ return {changed:!snap||snap.baseline.sha256!==S.baseline?.sha256,missing:!snap,mtimeMs:snap?.baseline.mtimeMs,size:snap?.baseline.size};
179
+ }
180
+ }
181
+ module.exports={DocumentStore,createSession,failure};