@plmbr/notebook-intelligence 5.0.0
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/LICENSE +674 -0
- package/README.md +412 -0
- package/lib/api.d.ts +288 -0
- package/lib/api.js +927 -0
- package/lib/cell-output-bundle.d.ts +25 -0
- package/lib/cell-output-bundle.js +129 -0
- package/lib/cell-output-toolbar.d.ts +26 -0
- package/lib/cell-output-toolbar.js +188 -0
- package/lib/chat-progress-feedback.d.ts +3 -0
- package/lib/chat-progress-feedback.js +27 -0
- package/lib/chat-sidebar.d.ts +92 -0
- package/lib/chat-sidebar.js +3452 -0
- package/lib/command-ids.d.ts +39 -0
- package/lib/command-ids.js +44 -0
- package/lib/components/ask-user-question.d.ts +2 -0
- package/lib/components/ask-user-question.js +85 -0
- package/lib/components/checkbox.d.ts +2 -0
- package/lib/components/checkbox.js +30 -0
- package/lib/components/claude-mcp-panel.d.ts +2 -0
- package/lib/components/claude-mcp-panel.js +275 -0
- package/lib/components/claude-mcp-paste.d.ts +7 -0
- package/lib/components/claude-mcp-paste.js +104 -0
- package/lib/components/claude-session-picker.d.ts +8 -0
- package/lib/components/claude-session-picker.js +127 -0
- package/lib/components/form-dialog.d.ts +25 -0
- package/lib/components/form-dialog.js +35 -0
- package/lib/components/launcher-picker.d.ts +6 -0
- package/lib/components/launcher-picker.js +135 -0
- package/lib/components/mcp-util.d.ts +2 -0
- package/lib/components/mcp-util.js +37 -0
- package/lib/components/notebook-generation-popover.d.ts +7 -0
- package/lib/components/notebook-generation-popover.js +60 -0
- package/lib/components/pill.d.ts +2 -0
- package/lib/components/pill.js +5 -0
- package/lib/components/plugins-panel.d.ts +3 -0
- package/lib/components/plugins-panel.js +466 -0
- package/lib/components/settings-panel.d.ts +11 -0
- package/lib/components/settings-panel.js +742 -0
- package/lib/components/skills-panel.d.ts +2 -0
- package/lib/components/skills-panel.js +1264 -0
- package/lib/handler.d.ts +8 -0
- package/lib/handler.js +36 -0
- package/lib/icons.d.ts +45 -0
- package/lib/icons.js +54 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +2079 -0
- package/lib/markdown-renderer.d.ts +10 -0
- package/lib/markdown-renderer.js +64 -0
- package/lib/notebook-generation-toolbar.d.ts +16 -0
- package/lib/notebook-generation-toolbar.js +197 -0
- package/lib/notebook-generation.d.ts +8 -0
- package/lib/notebook-generation.js +12 -0
- package/lib/open-file-refresh-watcher-env.d.ts +4 -0
- package/lib/open-file-refresh-watcher-env.js +33 -0
- package/lib/open-file-refresh-watcher.d.ts +97 -0
- package/lib/open-file-refresh-watcher.js +190 -0
- package/lib/shell-utils.d.ts +6 -0
- package/lib/shell-utils.js +9 -0
- package/lib/task-target-notebook.d.ts +2 -0
- package/lib/task-target-notebook.js +28 -0
- package/lib/terminal-drag-format.d.ts +9 -0
- package/lib/terminal-drag-format.js +23 -0
- package/lib/terminal-drag.d.ts +12 -0
- package/lib/terminal-drag.js +268 -0
- package/lib/tokens.d.ts +149 -0
- package/lib/tokens.js +88 -0
- package/lib/tour/tour-anchors.d.ts +18 -0
- package/lib/tour/tour-anchors.js +18 -0
- package/lib/tour/tour-config.d.ts +66 -0
- package/lib/tour/tour-config.js +99 -0
- package/lib/tour/tour-defaults.json +58 -0
- package/lib/tour/tour-events.d.ts +19 -0
- package/lib/tour/tour-events.js +30 -0
- package/lib/tour/tour-overlay.d.ts +6 -0
- package/lib/tour/tour-overlay.js +350 -0
- package/lib/tour/tour-state.d.ts +20 -0
- package/lib/tour/tour-state.js +81 -0
- package/lib/tour/tour-steps.d.ts +33 -0
- package/lib/tour/tour-steps.js +216 -0
- package/lib/utils.d.ts +53 -0
- package/lib/utils.js +385 -0
- package/package.json +258 -0
- package/schema/plugin.json +42 -0
- package/src/api.ts +1424 -0
- package/src/cell-output-bundle.ts +176 -0
- package/src/cell-output-toolbar.ts +232 -0
- package/src/chat-progress-feedback.ts +35 -0
- package/src/chat-sidebar.tsx +5147 -0
- package/src/command-ids.ts +67 -0
- package/src/components/ask-user-question.tsx +151 -0
- package/src/components/checkbox.tsx +62 -0
- package/src/components/claude-mcp-panel.tsx +543 -0
- package/src/components/claude-mcp-paste.ts +132 -0
- package/src/components/claude-session-picker.tsx +214 -0
- package/src/components/form-dialog.tsx +75 -0
- package/src/components/launcher-picker.tsx +237 -0
- package/src/components/mcp-util.ts +53 -0
- package/src/components/notebook-generation-popover.tsx +127 -0
- package/src/components/pill.tsx +15 -0
- package/src/components/plugins-panel.tsx +774 -0
- package/src/components/settings-panel.tsx +1631 -0
- package/src/components/skills-panel.tsx +2084 -0
- package/src/handler.ts +51 -0
- package/src/icons.ts +71 -0
- package/src/index.ts +2583 -0
- package/src/markdown-renderer.tsx +153 -0
- package/src/notebook-generation-toolbar.tsx +281 -0
- package/src/notebook-generation.ts +23 -0
- package/src/open-file-refresh-watcher-env.ts +52 -0
- package/src/open-file-refresh-watcher.ts +260 -0
- package/src/shell-utils.ts +10 -0
- package/src/svg.d.ts +4 -0
- package/src/task-target-notebook.ts +37 -0
- package/src/terminal-drag-format.ts +29 -0
- package/src/terminal-drag.ts +382 -0
- package/src/tokens.ts +171 -0
- package/src/tour/tour-anchors.ts +21 -0
- package/src/tour/tour-config.ts +160 -0
- package/src/tour/tour-events.ts +34 -0
- package/src/tour/tour-overlay.tsx +474 -0
- package/src/tour/tour-state.ts +87 -0
- package/src/tour/tour-steps.ts +281 -0
- package/src/utils.ts +455 -0
- package/style/base.css +3238 -0
- package/style/icons/cell-toolbar-bug.svg +5 -0
- package/style/icons/cell-toolbar-chat.svg +5 -0
- package/style/icons/cell-toolbar-sparkle.svg +5 -0
- package/style/icons/claude.svg +1 -0
- package/style/icons/copilot-warning.svg +1 -0
- package/style/icons/copilot.svg +1 -0
- package/style/icons/copy.svg +1 -0
- package/style/icons/openai.svg +1 -0
- package/style/icons/opencode.svg +1 -0
- package/style/icons/sparkles-warning.svg +5 -0
- package/style/icons/sparkles.svg +1 -0
- package/style/index.css +1 -0
- package/style/index.js +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
3
|
+
import { IActiveDocumentInfo } from './tokens';
|
|
4
|
+
type MarkdownRendererProps = {
|
|
5
|
+
children: string;
|
|
6
|
+
getApp: () => JupyterFrontEnd;
|
|
7
|
+
getActiveDocumentInfo(): IActiveDocumentInfo;
|
|
8
|
+
};
|
|
9
|
+
export declare function MarkdownRenderer({ children: markdown, getApp, getActiveDocumentInfo }: MarkdownRendererProps): React.JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import Markdown from 'react-markdown';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
import { Prism as SyntaxHighlighterBase } from 'react-syntax-highlighter';
|
|
6
|
+
const SyntaxHighlighter = SyntaxHighlighterBase;
|
|
7
|
+
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
|
8
|
+
import { VscNewFile, VscInsert, VscCopy, VscNotebook, VscAdd } from './icons';
|
|
9
|
+
import { isDarkTheme, writeTextToClipboard } from './utils';
|
|
10
|
+
export function MarkdownRenderer({ children: markdown, getApp, getActiveDocumentInfo }) {
|
|
11
|
+
const app = getApp();
|
|
12
|
+
const activeDocumentInfo = getActiveDocumentInfo();
|
|
13
|
+
const isNotebook = activeDocumentInfo.filename.endsWith('.ipynb');
|
|
14
|
+
return (React.createElement(Markdown, { remarkPlugins: [remarkGfm], components: {
|
|
15
|
+
code({ node, inline, className, children, getApp, ...props }) {
|
|
16
|
+
const match = /language-(\w+)/.exec(className || '');
|
|
17
|
+
const codeString = String(children).replace(/\n$/, '');
|
|
18
|
+
const language = match ? match[1] : 'text';
|
|
19
|
+
const handleCopyClick = () => {
|
|
20
|
+
void writeTextToClipboard(codeString);
|
|
21
|
+
};
|
|
22
|
+
const handleInsertAtCursorClick = () => {
|
|
23
|
+
app.commands.execute('notebook-intelligence:insert-at-cursor', {
|
|
24
|
+
language,
|
|
25
|
+
code: codeString
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
const handleAddCodeAsNewCell = () => {
|
|
29
|
+
app.commands.execute('notebook-intelligence:add-code-as-new-cell', {
|
|
30
|
+
language,
|
|
31
|
+
code: codeString
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
const handleCreateNewFileClick = () => {
|
|
35
|
+
app.commands.execute('notebook-intelligence:create-new-file', {
|
|
36
|
+
language,
|
|
37
|
+
code: codeString
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const handleCreateNewNotebookClick = () => {
|
|
41
|
+
app.commands.execute('notebook-intelligence:create-new-notebook-from-py', { language, code: codeString });
|
|
42
|
+
};
|
|
43
|
+
if (inline || !match) {
|
|
44
|
+
return (React.createElement("code", { className: className, ...props }, children));
|
|
45
|
+
}
|
|
46
|
+
return (React.createElement("div", null,
|
|
47
|
+
React.createElement("div", { className: "code-block-header" },
|
|
48
|
+
React.createElement("div", { className: "code-block-header-language" },
|
|
49
|
+
React.createElement("span", null, language)),
|
|
50
|
+
React.createElement("button", { type: "button", className: "code-block-header-button", onClick: () => handleCopyClick(), "aria-label": "Copy code to clipboard" },
|
|
51
|
+
React.createElement(VscCopy, { size: 16, "aria-hidden": "true" }),
|
|
52
|
+
React.createElement("span", null, "Copy")),
|
|
53
|
+
React.createElement("button", { type: "button", className: "code-block-header-button", onClick: () => handleInsertAtCursorClick(), "aria-label": "Insert code at cursor", title: "Insert at cursor" },
|
|
54
|
+
React.createElement(VscInsert, { size: 16, "aria-hidden": "true" })),
|
|
55
|
+
isNotebook && (React.createElement("button", { type: "button", className: "code-block-header-button", onClick: () => handleAddCodeAsNewCell(), "aria-label": "Add code as new cell", title: "Add as new cell" },
|
|
56
|
+
React.createElement(VscAdd, { size: 16, "aria-hidden": "true" }))),
|
|
57
|
+
React.createElement("button", { type: "button", className: "code-block-header-button", onClick: () => handleCreateNewFileClick(), "aria-label": "Create new file from code", title: "New file" },
|
|
58
|
+
React.createElement(VscNewFile, { size: 16, "aria-hidden": "true" })),
|
|
59
|
+
language === 'python' && (React.createElement("button", { type: "button", className: "code-block-header-button", onClick: () => handleCreateNewNotebookClick(), "aria-label": "Create new notebook from code", title: "New notebook" },
|
|
60
|
+
React.createElement(VscNotebook, { size: 16, "aria-hidden": "true" })))),
|
|
61
|
+
React.createElement(SyntaxHighlighter, { style: isDarkTheme() ? oneDark : oneLight, PreTag: "div", language: language, ...props }, codeString)));
|
|
62
|
+
}
|
|
63
|
+
} }, markdown));
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
|
+
import { DocumentRegistry } from '@jupyterlab/docregistry';
|
|
3
|
+
import { INotebookModel, NotebookPanel } from '@jupyterlab/notebook';
|
|
4
|
+
import { LabIcon } from '@jupyterlab/ui-components';
|
|
5
|
+
import { IDisposable } from '@lumino/disposable';
|
|
6
|
+
interface INotebookGenerationToolbarOptions {
|
|
7
|
+
app: JupyterFrontEnd;
|
|
8
|
+
icon: LabIcon;
|
|
9
|
+
chatSidebarId: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class NotebookGenerationToolbarExtension implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel> {
|
|
12
|
+
constructor(options: INotebookGenerationToolbarOptions);
|
|
13
|
+
createNew(panel: NotebookPanel, _context: DocumentRegistry.IContext<INotebookModel>): IDisposable;
|
|
14
|
+
private _options;
|
|
15
|
+
}
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
import { ReactWidget } from '@jupyterlab/apputils';
|
|
3
|
+
import { ToolbarButton } from '@jupyterlab/ui-components';
|
|
4
|
+
import { DisposableDelegate } from '@lumino/disposable';
|
|
5
|
+
import { Widget } from '@lumino/widgets';
|
|
6
|
+
import { UUID } from '@lumino/coreutils';
|
|
7
|
+
import React from 'react';
|
|
8
|
+
import { RunChatCompletionType } from './chat-sidebar';
|
|
9
|
+
import { buildNotebookGenerationPrompt, NOTEBOOK_GENERATION_PROGRESS_EVENT } from './notebook-generation';
|
|
10
|
+
import { NotebookGenerationPopover } from './components/notebook-generation-popover';
|
|
11
|
+
const TOOLBAR_BUTTON_NAME = 'nbi-generate-notebook';
|
|
12
|
+
const TOOLBAR_STATUS_NAME = 'nbi-generate-notebook-status';
|
|
13
|
+
class NotebookGenerationPopoverWidget extends ReactWidget {
|
|
14
|
+
constructor(options) {
|
|
15
|
+
super();
|
|
16
|
+
this._onDocumentMouseDown = (event) => {
|
|
17
|
+
const target = event.target;
|
|
18
|
+
if (target && this.node.contains(target)) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
this._options.onClose();
|
|
22
|
+
};
|
|
23
|
+
this.addClass('nbi-notebook-generation-popover-host');
|
|
24
|
+
this._options = options;
|
|
25
|
+
}
|
|
26
|
+
onAfterAttach() {
|
|
27
|
+
document.addEventListener('mousedown', this._onDocumentMouseDown, true);
|
|
28
|
+
}
|
|
29
|
+
onBeforeDetach() {
|
|
30
|
+
document.removeEventListener('mousedown', this._onDocumentMouseDown, true);
|
|
31
|
+
}
|
|
32
|
+
positionAt(rect) {
|
|
33
|
+
const popoverWidth = 360;
|
|
34
|
+
const margin = 8;
|
|
35
|
+
let left = rect.left;
|
|
36
|
+
if (left + popoverWidth + margin > window.innerWidth) {
|
|
37
|
+
left = Math.max(margin, window.innerWidth - popoverWidth - margin);
|
|
38
|
+
}
|
|
39
|
+
const top = rect.bottom + 4;
|
|
40
|
+
this.node.style.position = 'fixed';
|
|
41
|
+
this.node.style.left = `${left}px`;
|
|
42
|
+
this.node.style.top = `${top}px`;
|
|
43
|
+
this.node.style.width = `${popoverWidth}px`;
|
|
44
|
+
this.node.style.zIndex = '10000';
|
|
45
|
+
}
|
|
46
|
+
render() {
|
|
47
|
+
return (React.createElement(NotebookGenerationPopover, { initialShowInChat: this._options.initialShowInChat, onSubmit: this._options.onSubmit, onClose: this._options.onClose }));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
class NotebookGenerationToolbarController {
|
|
51
|
+
constructor(options, panel) {
|
|
52
|
+
this._onProgress = (event) => {
|
|
53
|
+
const detail = event
|
|
54
|
+
.detail;
|
|
55
|
+
if (!detail || detail.requestId !== this._activeProgressRequestId) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!detail.inProgress) {
|
|
59
|
+
document.removeEventListener(NOTEBOOK_GENERATION_PROGRESS_EVENT, this._onProgress);
|
|
60
|
+
this._activeProgressRequestId = null;
|
|
61
|
+
if (detail.error) {
|
|
62
|
+
this._setStatus(`Generation failed: ${detail.error}`);
|
|
63
|
+
this._scheduleStatusHide(4000);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
this._setStatus('Notebook generation complete');
|
|
67
|
+
this._scheduleStatusHide(2500);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
this._popover = null;
|
|
72
|
+
this._activeProgressRequestId = null;
|
|
73
|
+
this._statusWidget = null;
|
|
74
|
+
this._statusHideTimer = null;
|
|
75
|
+
this._app = options.app;
|
|
76
|
+
this._chatSidebarId = options.chatSidebarId;
|
|
77
|
+
this._panel = panel;
|
|
78
|
+
}
|
|
79
|
+
openPopover(button) {
|
|
80
|
+
if (this._popover) {
|
|
81
|
+
this.closePopover();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const buttonRect = button.node.getBoundingClientRect();
|
|
85
|
+
this._popover = new NotebookGenerationPopoverWidget({
|
|
86
|
+
initialShowInChat: NotebookGenerationToolbarController._showInChat,
|
|
87
|
+
onSubmit: (prompt, showInChat) => {
|
|
88
|
+
NotebookGenerationToolbarController._showInChat = showInChat;
|
|
89
|
+
this._submitPrompt(prompt, showInChat);
|
|
90
|
+
this.closePopover();
|
|
91
|
+
},
|
|
92
|
+
onClose: () => this.closePopover()
|
|
93
|
+
});
|
|
94
|
+
Widget.attach(this._popover, document.body);
|
|
95
|
+
// ReactWidget renders on update-request; Widget.attach doesn't queue one.
|
|
96
|
+
this._popover.update();
|
|
97
|
+
this._popover.positionAt(buttonRect);
|
|
98
|
+
}
|
|
99
|
+
closePopover() {
|
|
100
|
+
if (!this._popover) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
this._popover.dispose();
|
|
104
|
+
this._popover = null;
|
|
105
|
+
}
|
|
106
|
+
dispose() {
|
|
107
|
+
this.closePopover();
|
|
108
|
+
if (this._activeProgressRequestId) {
|
|
109
|
+
document.removeEventListener(NOTEBOOK_GENERATION_PROGRESS_EVENT, this._onProgress);
|
|
110
|
+
this._activeProgressRequestId = null;
|
|
111
|
+
}
|
|
112
|
+
if (this._statusHideTimer !== null) {
|
|
113
|
+
clearTimeout(this._statusHideTimer);
|
|
114
|
+
this._statusHideTimer = null;
|
|
115
|
+
}
|
|
116
|
+
this._setStatus(null);
|
|
117
|
+
}
|
|
118
|
+
_submitPrompt(rawPrompt, showInChat) {
|
|
119
|
+
const prefixedPrompt = buildNotebookGenerationPrompt(rawPrompt);
|
|
120
|
+
const externalRequestId = UUID.uuid4();
|
|
121
|
+
// chatMode and toolSelections are forced by the chat-sidebar handler
|
|
122
|
+
// (NotebookGeneration always needs agent mode + the notebook-edit
|
|
123
|
+
// toolset). Leaving them off the request keeps the toolbar
|
|
124
|
+
// independent of the sidebar's current configuration.
|
|
125
|
+
const request = {
|
|
126
|
+
type: RunChatCompletionType.NotebookGeneration,
|
|
127
|
+
content: prefixedPrompt,
|
|
128
|
+
externalRequestId,
|
|
129
|
+
hideInChat: !showInChat
|
|
130
|
+
};
|
|
131
|
+
if (!showInChat) {
|
|
132
|
+
this._setStatus('Generating notebook…');
|
|
133
|
+
this._activeProgressRequestId = externalRequestId;
|
|
134
|
+
document.addEventListener(NOTEBOOK_GENERATION_PROGRESS_EVENT, this._onProgress);
|
|
135
|
+
}
|
|
136
|
+
document.dispatchEvent(new CustomEvent('copilotSidebar:runPrompt', { detail: request }));
|
|
137
|
+
if (showInChat) {
|
|
138
|
+
this._app.commands.execute('tabsmenu:activate-by-id', {
|
|
139
|
+
id: this._chatSidebarId
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
_scheduleStatusHide(delayMs) {
|
|
144
|
+
if (this._statusHideTimer !== null) {
|
|
145
|
+
clearTimeout(this._statusHideTimer);
|
|
146
|
+
}
|
|
147
|
+
this._statusHideTimer = setTimeout(() => {
|
|
148
|
+
this._statusHideTimer = null;
|
|
149
|
+
this._setStatus(null);
|
|
150
|
+
}, delayMs);
|
|
151
|
+
}
|
|
152
|
+
_setStatus(message) {
|
|
153
|
+
if (this._panel.isDisposed) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (!message) {
|
|
157
|
+
if (this._statusWidget) {
|
|
158
|
+
this._statusWidget.dispose();
|
|
159
|
+
this._statusWidget = null;
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!this._statusWidget) {
|
|
164
|
+
const widget = new Widget();
|
|
165
|
+
widget.addClass('nbi-notebook-generation-status');
|
|
166
|
+
this._panel.toolbar.insertAfter(TOOLBAR_BUTTON_NAME, TOOLBAR_STATUS_NAME, widget);
|
|
167
|
+
this._statusWidget = widget;
|
|
168
|
+
}
|
|
169
|
+
this._statusWidget.node.textContent = message;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Defaults ON; remembers the user's last choice for the rest of the tab.
|
|
173
|
+
NotebookGenerationToolbarController._showInChat = true;
|
|
174
|
+
export class NotebookGenerationToolbarExtension {
|
|
175
|
+
constructor(options) {
|
|
176
|
+
this._options = options;
|
|
177
|
+
}
|
|
178
|
+
createNew(panel, _context) {
|
|
179
|
+
const controller = new NotebookGenerationToolbarController(this._options, panel);
|
|
180
|
+
const button = new ToolbarButton({
|
|
181
|
+
icon: this._options.icon,
|
|
182
|
+
onClick: () => controller.openPopover(button),
|
|
183
|
+
// Notebook generation works in any chat mode — the chat-sidebar
|
|
184
|
+
// handler forces agent mode and the notebook-edit toolset when
|
|
185
|
+
// routing this request (issue #229). The button used to be gated
|
|
186
|
+
// on isInClaudeCodeMode here, but that gate undermined the
|
|
187
|
+
// sidebar-side fix.
|
|
188
|
+
tooltip: 'Update active notebook with AI'
|
|
189
|
+
});
|
|
190
|
+
button.addClass('nbi-notebook-generation-toolbar-button');
|
|
191
|
+
panel.toolbar.insertAfter('cellType', TOOLBAR_BUTTON_NAME, button);
|
|
192
|
+
return new DisposableDelegate(() => {
|
|
193
|
+
controller.dispose();
|
|
194
|
+
button.dispose();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const NOTEBOOK_GENERATION_PROMPT_PREFIX = "Update active notebook based on the user request. Don't create a new notebook, always update the active one. User request: ";
|
|
2
|
+
export declare const NOTEBOOK_GENERATION_PROGRESS_EVENT = "copilotSidebar:notebookGenerationProgress";
|
|
3
|
+
export interface INotebookGenerationProgressDetail {
|
|
4
|
+
requestId: string;
|
|
5
|
+
inProgress: boolean;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function buildNotebookGenerationPrompt(rawPrompt: string): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
//
|
|
3
|
+
// Pure helpers and event constants used by the notebook-generation toolbar
|
|
4
|
+
// button. Keeping these in their own module (free of JupyterLab/React
|
|
5
|
+
// dependencies) makes them easy to unit-test under jsdom without bringing
|
|
6
|
+
// in the chat sidebar's heavyweight ESM imports.
|
|
7
|
+
export const NOTEBOOK_GENERATION_PROMPT_PREFIX = "Update active notebook based on the user request. Don't create a new notebook, always update the active one. User request: ";
|
|
8
|
+
export const NOTEBOOK_GENERATION_PROGRESS_EVENT = 'copilotSidebar:notebookGenerationProgress';
|
|
9
|
+
export function buildNotebookGenerationPrompt(rawPrompt) {
|
|
10
|
+
const trimmed = (rawPrompt || '').trim();
|
|
11
|
+
return `${NOTEBOOK_GENERATION_PROMPT_PREFIX}${trimmed}`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
|
+
import { Contents } from '@jupyterlab/services';
|
|
3
|
+
import { IRefreshWatcherEnv } from './open-file-refresh-watcher';
|
|
4
|
+
export declare function buildRefreshWatcherEnv(app: JupyterFrontEnd, contents: Contents.IManager): IRefreshWatcherEnv;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
import { DocumentWidget } from '@jupyterlab/docregistry';
|
|
3
|
+
import { WATCHED_SHELL_AREAS } from './open-file-refresh-watcher';
|
|
4
|
+
export function buildRefreshWatcherEnv(app, contents) {
|
|
5
|
+
return {
|
|
6
|
+
iterDocumentWidgets: function* () {
|
|
7
|
+
for (const area of WATCHED_SHELL_AREAS) {
|
|
8
|
+
// Defensive try/catch: LabShell.widgets() throws for areas it
|
|
9
|
+
// doesn't implement. We've audited the current set against
|
|
10
|
+
// the runtime, but a future JL bump could rename or remove
|
|
11
|
+
// an area; surfacing it as a console warning rather than an
|
|
12
|
+
// unhandled rejection keeps the watcher running for the
|
|
13
|
+
// areas that DO work.
|
|
14
|
+
let widgets;
|
|
15
|
+
try {
|
|
16
|
+
widgets = app.shell.widgets(area);
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
console.warn(`[NBI] open-file-refresh-watcher: skipping shell area "${area}":`, err);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
for (const widget of widgets) {
|
|
23
|
+
if (widget instanceof DocumentWidget) {
|
|
24
|
+
yield widget;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
fetchDiskModel: path => contents.get(path, { content: false }),
|
|
30
|
+
setInterval: (handler, ms) => window.setInterval(handler, ms),
|
|
31
|
+
clearInterval: handle => window.clearInterval(handle)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { DocumentRegistry } from '@jupyterlab/docregistry';
|
|
2
|
+
import type { Contents } from '@jupyterlab/services';
|
|
3
|
+
export interface IRefreshWatcherWidget {
|
|
4
|
+
readonly context: DocumentRegistry.Context | null | undefined;
|
|
5
|
+
}
|
|
6
|
+
export declare const DEFAULT_REFRESH_POLL_INTERVAL_MS = 3000;
|
|
7
|
+
export declare const DEFAULT_TICK_CONCURRENCY = 4;
|
|
8
|
+
export declare const WATCHED_SHELL_AREAS: readonly ["main", "left", "right"];
|
|
9
|
+
/**
|
|
10
|
+
* Inputs the revert decision depends on. Keeping this pure (no
|
|
11
|
+
* JupyterLab types) lets the unit test pin the policy without
|
|
12
|
+
* instantiating a real Context.
|
|
13
|
+
*/
|
|
14
|
+
export interface IRevertDecisionInputs {
|
|
15
|
+
diskLastModified: string | null | undefined;
|
|
16
|
+
contextLastModified: string | null | undefined;
|
|
17
|
+
isDirty: boolean;
|
|
18
|
+
isReady: boolean;
|
|
19
|
+
isDisposed: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether the open document's in-memory model should be reverted to
|
|
23
|
+
* match the on-disk version. The rules, in order:
|
|
24
|
+
*
|
|
25
|
+
* 1. Skip if the context is gone (disposed) or not yet populated
|
|
26
|
+
* (`isReady` false). Calling `revert()` against an unready
|
|
27
|
+
* context races the initial load.
|
|
28
|
+
* 2. Skip if the user has unsaved local edits (`isDirty`). Silently
|
|
29
|
+
* clobbering their work would be hostile; the standard
|
|
30
|
+
* JupyterLab "newer on disk" prompt will surface on save.
|
|
31
|
+
* 3. Skip if we can't parse either timestamp (either side missing
|
|
32
|
+
* or unparseable).
|
|
33
|
+
* 4. Revert iff disk's `last_modified` parses to a strictly greater
|
|
34
|
+
* epoch ms than the context's last-known value. Equal means
|
|
35
|
+
* already current (a save we initiated, or a no-op re-read).
|
|
36
|
+
*
|
|
37
|
+
* The comparison is numeric, not lexicographic, because jupyter_server
|
|
38
|
+
* serializes `last_modified` via Python's `datetime.isoformat()` which
|
|
39
|
+
* omits the fractional component when `microsecond == 0`. That means
|
|
40
|
+
* the same instant can arrive as `"...:56Z"` or `"...:56.000000Z"`,
|
|
41
|
+
* and `"...:56.000000Z" < "...:56Z"` lexicographically (the `.` at
|
|
42
|
+
* 0x2E sorts below the `Z` at 0x5A at position 19). A string compare
|
|
43
|
+
* would fire a spurious revert when the two sides happen to land on
|
|
44
|
+
* different sides of the fractional/non-fractional boundary for the
|
|
45
|
+
* same mtime. Parsing both through `new Date(s).getTime()` collapses
|
|
46
|
+
* them to the same epoch ms.
|
|
47
|
+
*
|
|
48
|
+
* JupyterLab's own newer-on-disk check (`docregistry/lib/context.js`,
|
|
49
|
+
* see `lastModifiedCheckMargin`) applies a 500ms tolerance to absorb
|
|
50
|
+
* filesystem mtime jitter, but that comparison drives a user-facing
|
|
51
|
+
* "newer on disk, save anyway?" prompt where false-positive is just a
|
|
52
|
+
* dialog. Our comparison drives a silent revert that could clobber an
|
|
53
|
+
* agent's edit that landed within the margin of the user's own save;
|
|
54
|
+
* we deliberately stay strict.
|
|
55
|
+
*/
|
|
56
|
+
export declare function shouldRevertContext({ diskLastModified, contextLastModified, isDirty, isReady, isDisposed }: IRevertDecisionInputs): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Side-effect surface the watcher reaches into. Extracted so tests
|
|
59
|
+
* can pass a thin fake without standing up a real JupyterFrontEnd or
|
|
60
|
+
* Contents singleton.
|
|
61
|
+
*/
|
|
62
|
+
export interface IRefreshWatcherEnv {
|
|
63
|
+
/** Yield every currently-open document widget the watcher should consider. */
|
|
64
|
+
iterDocumentWidgets: () => Iterable<IRefreshWatcherWidget>;
|
|
65
|
+
/** Fetch on-disk metadata without the body (cheap stat-shaped call). */
|
|
66
|
+
fetchDiskModel: (path: string) => Promise<Contents.IModel>;
|
|
67
|
+
/** Set/clear the polling interval. Pulled out for fake timers in tests. */
|
|
68
|
+
setInterval: (handler: () => void, ms: number) => unknown;
|
|
69
|
+
clearInterval: (handle: unknown) => void;
|
|
70
|
+
}
|
|
71
|
+
export interface IRefreshWatcherOptions {
|
|
72
|
+
env: IRefreshWatcherEnv;
|
|
73
|
+
intervalMs?: number;
|
|
74
|
+
/** Cap on concurrent Contents.get calls per tick. Defaults to DEFAULT_TICK_CONCURRENCY. */
|
|
75
|
+
tickConcurrency?: number;
|
|
76
|
+
/** Re-checked on every tick so a settings toggle takes effect without restart. */
|
|
77
|
+
isEnabled: () => boolean;
|
|
78
|
+
/** Hook for tests / telemetry — fired once per revert (the heavy outcome). */
|
|
79
|
+
onRevert?: (path: string) => void;
|
|
80
|
+
/** Hook for tests / diagnostics — fired when a check throws. */
|
|
81
|
+
onError?: (path: string, error: unknown) => void;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Polls every open document widget on a fixed cadence, comparing the
|
|
85
|
+
* file's on-disk `last_modified` against the context's last-known
|
|
86
|
+
* value, and calls `context.revert()` when the disk is newer.
|
|
87
|
+
*
|
|
88
|
+
* Why polling at all (when the Contents API exposes a `fileChanged`
|
|
89
|
+
* signal): agents like Claude write directly to the filesystem,
|
|
90
|
+
* bypassing the API. The signal fires for Lab-routed writes only.
|
|
91
|
+
* Polling catches both paths uniformly and keeps the watcher
|
|
92
|
+
* single-purpose.
|
|
93
|
+
*
|
|
94
|
+
* Returns a teardown function the caller invokes on plugin
|
|
95
|
+
* deactivation to stop the timer.
|
|
96
|
+
*/
|
|
97
|
+
export declare function attachOpenFileRefreshWatcher(options: IRefreshWatcherOptions): () => void;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
export const DEFAULT_REFRESH_POLL_INTERVAL_MS = 3000;
|
|
3
|
+
// Max in-flight Contents.get calls per tick. The Jupyter server runs
|
|
4
|
+
// each request through its own handler thread; with 15 open tabs and
|
|
5
|
+
// unthrottled fan-out we'd pin 15 sockets every poll. Cap at 4 so a
|
|
6
|
+
// heavy-tab user still finishes a tick well under the 3s interval
|
|
7
|
+
// without hammering the server.
|
|
8
|
+
export const DEFAULT_TICK_CONCURRENCY = 4;
|
|
9
|
+
// Shell areas the live binding walks looking for open DocumentWidgets.
|
|
10
|
+
// 'main' is the primary editor area (including split panes managed by
|
|
11
|
+
// the underlying DockPanel); 'left' and 'right' catch the rare case
|
|
12
|
+
// where a user drags a notebook tab into a sidebar in JL4.
|
|
13
|
+
//
|
|
14
|
+
// 'down' is intentionally absent. It's listed in JupyterLab's
|
|
15
|
+
// TypeScript Area union (application/lib/shell.d.ts) but NOT
|
|
16
|
+
// implemented in LabShell.widgets()'s runtime switch
|
|
17
|
+
// (application/lib/shell.js) — asking for it throws
|
|
18
|
+
// `Invalid area: down`. Reported on PR #330 review; do not re-add
|
|
19
|
+
// without confirming the runtime impl in the installed JL version.
|
|
20
|
+
//
|
|
21
|
+
// The constant lives here (rather than in the env binding) so the
|
|
22
|
+
// test suite can pin its contents without importing
|
|
23
|
+
// @jupyterlab/docregistry, which ships ESM that ts-jest's default
|
|
24
|
+
// transform can't parse.
|
|
25
|
+
export const WATCHED_SHELL_AREAS = ['main', 'left', 'right'];
|
|
26
|
+
/**
|
|
27
|
+
* Whether the open document's in-memory model should be reverted to
|
|
28
|
+
* match the on-disk version. The rules, in order:
|
|
29
|
+
*
|
|
30
|
+
* 1. Skip if the context is gone (disposed) or not yet populated
|
|
31
|
+
* (`isReady` false). Calling `revert()` against an unready
|
|
32
|
+
* context races the initial load.
|
|
33
|
+
* 2. Skip if the user has unsaved local edits (`isDirty`). Silently
|
|
34
|
+
* clobbering their work would be hostile; the standard
|
|
35
|
+
* JupyterLab "newer on disk" prompt will surface on save.
|
|
36
|
+
* 3. Skip if we can't parse either timestamp (either side missing
|
|
37
|
+
* or unparseable).
|
|
38
|
+
* 4. Revert iff disk's `last_modified` parses to a strictly greater
|
|
39
|
+
* epoch ms than the context's last-known value. Equal means
|
|
40
|
+
* already current (a save we initiated, or a no-op re-read).
|
|
41
|
+
*
|
|
42
|
+
* The comparison is numeric, not lexicographic, because jupyter_server
|
|
43
|
+
* serializes `last_modified` via Python's `datetime.isoformat()` which
|
|
44
|
+
* omits the fractional component when `microsecond == 0`. That means
|
|
45
|
+
* the same instant can arrive as `"...:56Z"` or `"...:56.000000Z"`,
|
|
46
|
+
* and `"...:56.000000Z" < "...:56Z"` lexicographically (the `.` at
|
|
47
|
+
* 0x2E sorts below the `Z` at 0x5A at position 19). A string compare
|
|
48
|
+
* would fire a spurious revert when the two sides happen to land on
|
|
49
|
+
* different sides of the fractional/non-fractional boundary for the
|
|
50
|
+
* same mtime. Parsing both through `new Date(s).getTime()` collapses
|
|
51
|
+
* them to the same epoch ms.
|
|
52
|
+
*
|
|
53
|
+
* JupyterLab's own newer-on-disk check (`docregistry/lib/context.js`,
|
|
54
|
+
* see `lastModifiedCheckMargin`) applies a 500ms tolerance to absorb
|
|
55
|
+
* filesystem mtime jitter, but that comparison drives a user-facing
|
|
56
|
+
* "newer on disk, save anyway?" prompt where false-positive is just a
|
|
57
|
+
* dialog. Our comparison drives a silent revert that could clobber an
|
|
58
|
+
* agent's edit that landed within the margin of the user's own save;
|
|
59
|
+
* we deliberately stay strict.
|
|
60
|
+
*/
|
|
61
|
+
export function shouldRevertContext({ diskLastModified, contextLastModified, isDirty, isReady, isDisposed }) {
|
|
62
|
+
if (isDisposed || !isReady) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (isDirty) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (!diskLastModified || !contextLastModified) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
// Match JL's own idiom (docregistry/lib/context.js:625-630) and the
|
|
72
|
+
// five other `new Date(...).getTime()` call sites in this codebase.
|
|
73
|
+
// Equivalent to `Date.parse(s)` but consistent with house style.
|
|
74
|
+
const diskMs = new Date(diskLastModified).getTime();
|
|
75
|
+
const contextMs = new Date(contextLastModified).getTime();
|
|
76
|
+
// NaN on either side (unparseable string) makes the comparison
|
|
77
|
+
// false, so a malformed timestamp degrades safely to "don't revert."
|
|
78
|
+
return diskMs > contextMs;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Polls every open document widget on a fixed cadence, comparing the
|
|
82
|
+
* file's on-disk `last_modified` against the context's last-known
|
|
83
|
+
* value, and calls `context.revert()` when the disk is newer.
|
|
84
|
+
*
|
|
85
|
+
* Why polling at all (when the Contents API exposes a `fileChanged`
|
|
86
|
+
* signal): agents like Claude write directly to the filesystem,
|
|
87
|
+
* bypassing the API. The signal fires for Lab-routed writes only.
|
|
88
|
+
* Polling catches both paths uniformly and keeps the watcher
|
|
89
|
+
* single-purpose.
|
|
90
|
+
*
|
|
91
|
+
* Returns a teardown function the caller invokes on plugin
|
|
92
|
+
* deactivation to stop the timer.
|
|
93
|
+
*/
|
|
94
|
+
export function attachOpenFileRefreshWatcher(options) {
|
|
95
|
+
var _a;
|
|
96
|
+
const intervalMs = (_a = options.intervalMs) !== null && _a !== void 0 ? _a : DEFAULT_REFRESH_POLL_INTERVAL_MS;
|
|
97
|
+
let inFlight = false;
|
|
98
|
+
let stopped = false;
|
|
99
|
+
const tick = async () => {
|
|
100
|
+
var _a;
|
|
101
|
+
// Defense against the browser firing a stale interval handler
|
|
102
|
+
// after we've called clearInterval, and against tests that invoke
|
|
103
|
+
// the captured handler directly post-teardown.
|
|
104
|
+
if (stopped) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!options.isEnabled()) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Re-entrancy guard: a slow Contents.get on one widget shouldn't
|
|
111
|
+
// pile up additional ticks while it resolves. Skip rather than
|
|
112
|
+
// queue so a transient server slowdown doesn't snowball.
|
|
113
|
+
if (inFlight) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
inFlight = true;
|
|
117
|
+
try {
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const targets = [];
|
|
120
|
+
for (const widget of options.env.iterDocumentWidgets()) {
|
|
121
|
+
const context = widget.context;
|
|
122
|
+
if (!context || !context.path) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// Dedupe across widgets sharing the same context (split-view,
|
|
126
|
+
// notebook + editor view, etc.); reverting once per path is
|
|
127
|
+
// enough since the context is the shared mutable state.
|
|
128
|
+
if (seen.has(context.path)) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
seen.add(context.path);
|
|
132
|
+
targets.push(context);
|
|
133
|
+
}
|
|
134
|
+
// Chunked fan-out: parallelism within a batch (so 4 tabs finish
|
|
135
|
+
// in one RTT instead of four), serialized across batches (so a
|
|
136
|
+
// 20-tab user doesn't pin 20 sockets at once). Per-context
|
|
137
|
+
// errors stay inside checkOneContext via its try/catch, so a
|
|
138
|
+
// Promise.all on the batch can't reject and tear the loop.
|
|
139
|
+
const concurrency = Math.max(1, (_a = options.tickConcurrency) !== null && _a !== void 0 ? _a : DEFAULT_TICK_CONCURRENCY);
|
|
140
|
+
for (let i = 0; i < targets.length; i += concurrency) {
|
|
141
|
+
const batch = targets.slice(i, i + concurrency);
|
|
142
|
+
await Promise.all(batch.map(ctx => checkOneContext(ctx, options)));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
inFlight = false;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
const handle = options.env.setInterval(() => {
|
|
150
|
+
// Swallow tick-level errors so a single bad path can't kill the
|
|
151
|
+
// poller; per-context errors are reported via onError above.
|
|
152
|
+
void tick();
|
|
153
|
+
}, intervalMs);
|
|
154
|
+
return () => {
|
|
155
|
+
stopped = true;
|
|
156
|
+
options.env.clearInterval(handle);
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
async function checkOneContext(context, options) {
|
|
160
|
+
var _a, _b, _c;
|
|
161
|
+
try {
|
|
162
|
+
const diskModel = await options.env.fetchDiskModel(context.path);
|
|
163
|
+
const decision = shouldRevertContext({
|
|
164
|
+
diskLastModified: diskModel.last_modified,
|
|
165
|
+
contextLastModified: (_a = context.contentsModel) === null || _a === void 0 ? void 0 : _a.last_modified,
|
|
166
|
+
isDirty: context.model.dirty,
|
|
167
|
+
isReady: context.isReady,
|
|
168
|
+
isDisposed: context.isDisposed
|
|
169
|
+
});
|
|
170
|
+
if (!decision) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Defense in depth: re-read dirty/disposed immediately before the
|
|
174
|
+
// revert call. Today this is strictly belt-and-suspenders — no
|
|
175
|
+
// microtask boundary exists between the dirty read inside
|
|
176
|
+
// shouldRevertContext above and the await on revert() below, so a
|
|
177
|
+
// keystroke cannot land in that window. The re-check survives a
|
|
178
|
+
// future refactor that inserts an await (telemetry, an instrument
|
|
179
|
+
// hook, etc.) between the decision and the revert call without
|
|
180
|
+
// anyone having to re-derive the safety argument.
|
|
181
|
+
if (context.model.dirty || context.isDisposed) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
await context.revert();
|
|
185
|
+
(_b = options.onRevert) === null || _b === void 0 ? void 0 : _b.call(options, context.path);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
(_c = options.onError) === null || _c === void 0 ? void 0 : _c.call(options, context.path, error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSIX-shell single-quote escape: every embedded single quote is closed,
|
|
3
|
+
* emitted as an escaped literal, and the quote re-opened. The result is
|
|
4
|
+
* safe to splice into a shell command without further sanitization.
|
|
5
|
+
*/
|
|
6
|
+
export declare function shellSingleQuote(value: string): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
/**
|
|
3
|
+
* POSIX-shell single-quote escape: every embedded single quote is closed,
|
|
4
|
+
* emitted as an escaped literal, and the quote re-opened. The result is
|
|
5
|
+
* safe to splice into a shell command without further sanitization.
|
|
6
|
+
*/
|
|
7
|
+
export function shellSingleQuote(value) {
|
|
8
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
9
|
+
}
|