From 745519eff6a037e1035d83aa007203ba0e72f14a Mon Sep 17 00:00:00 2001 From: John Date: Wed, 22 Jan 2025 16:38:34 +0800 Subject: [PATCH 1/3] feat: line-change of code edits adds change parameter (#4329) --- .../intelligent-completions/source/base.ts | 2 - .../source/line-change.source.ts | 103 ++++++++++++++---- 2 files changed, 79 insertions(+), 26 deletions(-) diff --git a/packages/ai-native/src/browser/contrib/intelligent-completions/source/base.ts b/packages/ai-native/src/browser/contrib/intelligent-completions/source/base.ts index 43835eccc7..14cda11858 100644 --- a/packages/ai-native/src/browser/contrib/intelligent-completions/source/base.ts +++ b/packages/ai-native/src/browser/contrib/intelligent-completions/source/base.ts @@ -69,8 +69,6 @@ export abstract class BaseCodeEditsSource extends Disposable { private cancellationTokenSource = new CancellationTokenSource(); private readonly relationID = observableValue(this, undefined); - protected abstract doTrigger(...args: any[]): MaybePromise; - public readonly codeEditsContextBean = disposableObservableValue(this, undefined); public abstract priority: number; public abstract mount(): IDisposable; diff --git a/packages/ai-native/src/browser/contrib/intelligent-completions/source/line-change.source.ts b/packages/ai-native/src/browser/contrib/intelligent-completions/source/line-change.source.ts index bf676358c8..0101098ca9 100644 --- a/packages/ai-native/src/browser/contrib/intelligent-completions/source/line-change.source.ts +++ b/packages/ai-native/src/browser/contrib/intelligent-completions/source/line-change.source.ts @@ -1,47 +1,102 @@ import { Injectable } from '@opensumi/di'; import { AINativeSettingSectionsId, ECodeEditsSourceTyping, IDisposable } from '@opensumi/ide-core-common'; -import { ICursorPositionChangedEvent, Position } from '@opensumi/ide-monaco'; +import { ICursorPositionChangedEvent, IModelContentChangedEvent } from '@opensumi/ide-monaco'; +import { + autorunDelta, + derivedHandleChanges, + observableFromEvent, + recomputeInitiallyAndOnChange, +} from '@opensumi/ide-monaco/lib/common/observable'; import { BaseCodeEditsSource } from './base'; export interface ILineChangeData { currentLineNumber: number; preLineNumber?: number; + change?: IModelContentChangedEvent; } @Injectable({ multiple: true }) export class LineChangeCodeEditsSource extends BaseCodeEditsSource { public priority = 2; - private prePosition = this.monacoEditor.getPosition(); - public mount(): IDisposable { + const modelContentChangeObs = observableFromEvent( + this, + this.monacoEditor.onDidChangeModelContent, + (event: IModelContentChangedEvent) => event, + ); + const positionChangeObs = observableFromEvent( + this, + this.monacoEditor.onDidChangeCursorPosition, + (event: ICursorPositionChangedEvent) => event, + ); + + const latestModelContentChangeObs = derivedHandleChanges( + { + owner: this, + createEmptyChangeSummary: () => ({ change: undefined }), + handleChange: (ctx, changeSummary: { change: IModelContentChangedEvent | undefined }) => { + // 如果只是改了光标则设置 change 为空,避免获取到缓存的 change + if (ctx.didChange(positionChangeObs)) { + changeSummary.change = undefined; + } else { + changeSummary.change = modelContentChangeObs.get(); + } + return true; + }, + }, + (reader, changeSummary) => { + positionChangeObs.read(reader); + modelContentChangeObs.read(reader); + return changeSummary.change; + }, + ); + + this.addDispose(recomputeInitiallyAndOnChange(latestModelContentChangeObs)); + + let lastModelContent: IModelContentChangedEvent | undefined; this.addDispose( - this.monacoEditor.onDidChangeCursorPosition((event: ICursorPositionChangedEvent) => { - const currentPosition = event.position; - if (this.prePosition && this.prePosition.lineNumber !== currentPosition.lineNumber) { - this.doTrigger(currentPosition); - this.prePosition = currentPosition; - } + /** + * 由于 monaco 的 changeModelContent 事件比 changeCursorPosition 事件先触发,所以这里需要拿上一次的值进行消费 + * 否则永远返回 undefined + */ + autorunDelta(latestModelContentChangeObs, ({ lastValue }) => { + lastModelContent = lastValue; }), ); - return this; - } - protected doTrigger(position: Position) { - const isLineChangeEnabled = this.preferenceService.getValid(AINativeSettingSectionsId.CodeEditsLineChange, false); + this.addDispose( + autorunDelta(positionChangeObs, ({ lastValue, newValue }) => { + const contentChange = lastModelContent; - if (!isLineChangeEnabled || !position) { - return; - } + const isLineChangeEnabled = this.preferenceService.getValid( + AINativeSettingSectionsId.CodeEditsLineChange, + false, + ); + if (!isLineChangeEnabled) { + return false; + } - this.setBean({ - typing: ECodeEditsSourceTyping.LineChange, - position, - data: { - preLineNumber: this.prePosition?.lineNumber, - currentLineNumber: position.lineNumber, - }, - }); + const prePosition = lastValue?.position; + const currentPosition = newValue?.position; + if (prePosition && prePosition.lineNumber !== currentPosition?.lineNumber) { + this.setBean({ + typing: ECodeEditsSourceTyping.LineChange, + position: currentPosition, + data: { + preLineNumber: prePosition.lineNumber, + currentLineNumber: currentPosition.lineNumber, + change: contentChange, + }, + }); + } + + // 消费完之后设置为 undefined,避免下次获取到缓存的值 + lastModelContent = undefined; + }), + ); + + return this; } } From a3003c978103a90072331022a3ee66604213fa6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E8=A1=A8=E5=93=A5?= Date: Thu, 23 Jan 2025 13:51:05 +0800 Subject: [PATCH 2/3] feat: add word link provider (#4327) * feat: add word link provider * chore: add test case * fix: terminal link hover --- .../browser/links/word-link-provider.test.ts | 75 ++++++++ .../src/browser/links/link-manager.ts | 12 +- .../src/browser/links/word-link-provider.ts | 175 ++++++++++++++++++ .../src/browser/terminal.hover.manager.ts | 8 +- .../src/common/xterm-private.d.ts | 11 +- 5 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 packages/terminal-next/__tests__/browser/links/word-link-provider.test.ts create mode 100644 packages/terminal-next/src/browser/links/word-link-provider.ts diff --git a/packages/terminal-next/__tests__/browser/links/word-link-provider.test.ts b/packages/terminal-next/__tests__/browser/links/word-link-provider.test.ts new file mode 100644 index 0000000000..40565cdee4 --- /dev/null +++ b/packages/terminal-next/__tests__/browser/links/word-link-provider.test.ts @@ -0,0 +1,75 @@ +import { ILink, Terminal } from '@xterm/xterm'; + +import { URI } from '@opensumi/ide-core-common'; +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; + +import { TerminalWordLinkProvider } from '../../../src/browser/links/word-link-provider'; + +describe('Workbench - TerminalWordLinkProvider', () => { + const injector = createBrowserInjector([]); + + async function assertLink(text: string, isWindows: boolean, expected: { text: string; range: [number, number][] }[]) { + const xterm = new Terminal({ allowProposedApi: true }); + const provider = injector.get(TerminalWordLinkProvider, [ + xterm, + (link: string, callback: (result: { uri: URI; isDirectory: boolean } | undefined) => void) => {}, + (_: string, cb: (result: { uri: URI; isDirectory: boolean } | undefined) => void) => { + cb({ uri: URI.file('/'), isDirectory: false }); + }, + ]); + + // Write the text and wait for the parser to finish + await new Promise((r) => xterm.write(text, r)); + + // Ensure all links are provided + const links = (await new Promise((r) => provider.provideLinks(1, r)))!; + expect(links.length).toStrictEqual(expected.length); + const actual = links.map((e) => ({ + text: e.text, + range: e.range, + })); + const expectedVerbose = expected.map((e) => ({ + text: e.text, + range: { + start: { x: e.range[0][0], y: e.range[0][1] }, + end: { x: e.range[1][0], y: e.range[1][1] }, + }, + })); + + expect(actual).toEqual(expectedVerbose); + } + + test('parse word link', async () => { + const text = '000000.mp4 dist dsada dsadas'; + await assertLink(text, false, [ + { + text: '000000.mp4', + range: [ + [1, 1], + [10, 1], + ], + }, + { + text: 'dist', + range: [ + [18, 1], + [21, 1], + ], + }, + { + text: 'dsada', + range: [ + [35, 1], + [39, 1], + ], + }, + { + text: 'dsadas', + range: [ + [52, 1], + [57, 1], + ], + }, + ]); + }); +}); diff --git a/packages/terminal-next/src/browser/links/link-manager.ts b/packages/terminal-next/src/browser/links/link-manager.ts index 001201c2b4..b7370a5037 100644 --- a/packages/terminal-next/src/browser/links/link-manager.ts +++ b/packages/terminal-next/src/browser/links/link-manager.ts @@ -41,6 +41,7 @@ import { winLineAndColumnMatchIndex, winLocalLinkClause, } from './validated-local-link-provider'; +import { TerminalWordLinkProvider } from './word-link-provider'; const { posix, win32 } = path; @@ -136,6 +137,13 @@ export class TerminalLinkManager extends Disposable { ]); this._standardLinkProviders.push(validatedProvider); + const wordLinkProvider = this.injector.get(TerminalWordLinkProvider, [ + this._xterm, + async (link, cb) => cb(await this._resolvePath(link)), + wrappedTextLinkActivateCallback, + ]); + this._standardLinkProviders.push(wordLinkProvider); + this._registerStandardLinkProviders(); } @@ -165,8 +173,8 @@ export class TerminalLinkManager extends Disposable { ) { const core = (this._xterm as any)._core as XTermCore; const cellDimensions = { - width: core._renderService.dimensions.actualCellWidth, - height: core._renderService.dimensions.actualCellHeight, + width: core._renderService.dimensions.css.cell.width, + height: core._renderService.dimensions.css.cell.height, }; const terminalDimensions = { width: this._xterm.cols, diff --git a/packages/terminal-next/src/browser/links/word-link-provider.ts b/packages/terminal-next/src/browser/links/word-link-provider.ts new file mode 100644 index 0000000000..3a0d2f1b23 --- /dev/null +++ b/packages/terminal-next/src/browser/links/word-link-provider.ts @@ -0,0 +1,175 @@ +import { IBufferLine, Terminal } from '@xterm/xterm'; + +import { Autowired, INJECTOR_TOKEN, Injectable, Injector } from '@opensumi/di'; +import { PrefixQuickOpenService } from '@opensumi/ide-core-browser/lib/quick-open'; +import { AppConfig } from '@opensumi/ide-core-browser/lib/react-providers/config-provider'; +import { IWindowService } from '@opensumi/ide-core-browser/lib/window'; +import { URI } from '@opensumi/ide-core-common'; +import { CommandService } from '@opensumi/ide-core-common/lib/command'; +import { IWorkspaceService } from '@opensumi/ide-workspace/lib/common/workspace.interface'; + +import { escapeRegExpCharacters } from '../terminal.typeAhead.addon'; + +import { TerminalBaseLinkProvider } from './base'; +import { convertLinkRangeToBuffer, getXtermLineContent } from './helpers'; +import { TerminalLink } from './link'; + +export const USUAL_WORD_SEPARATORS = ' ()[]{}\',"`─‘’“”|'; + +interface Word { + startIndex: number; + endIndex: number; + text: string; +} + +@Injectable({ multiple: true }) +export class TerminalWordLinkProvider extends TerminalBaseLinkProvider { + private _separatorRegex!: RegExp; + + @Autowired(AppConfig) + private readonly appConfig: AppConfig; + + @Autowired(CommandService) + private readonly commandService: CommandService; + + @Autowired(IWindowService) + protected readonly windowService: IWindowService; + + @Autowired(INJECTOR_TOKEN) + private readonly injector: Injector; + + @Autowired(IWorkspaceService) + private readonly workspaceService: IWorkspaceService; + + @Autowired(PrefixQuickOpenService) + protected readonly quickOpenService: PrefixQuickOpenService; + + constructor( + private readonly _xterm: Terminal, + private readonly _validationCallback: ( + link: string, + callback: (result: { uri: URI; isDirectory: boolean } | undefined) => void, + ) => void, + private readonly _activateFileCallback: (event: MouseEvent | undefined, link: string) => void, + ) { + super(); + this._refreshSeparatorCodes(); + } + + private _refreshSeparatorCodes(): void { + let powerlineSymbols = ''; + for (let i = 0xe0b0; i <= 0xe0bf; i++) { + powerlineSymbols += String.fromCharCode(i); + } + this._separatorRegex = new RegExp(`[${escapeRegExpCharacters(USUAL_WORD_SEPARATORS)}${powerlineSymbols}]`, 'g'); + } + + protected _provideLinks(bufferLineNumber: number): Promise | TerminalLink[] { + const links: TerminalLink[] = []; + const startLine = bufferLineNumber - 1; + const endLine = startLine; + + const lines: IBufferLine[] = [this._xterm.buffer.active.getLine(startLine)!]; + + const text = getXtermLineContent(this._xterm.buffer.active, startLine, endLine, this._xterm.cols); + if (text === '' || text.length > 1024) { + return []; + } + + // Parse out all words from the wrapped line + const words: Word[] = this._parseWords(text); + + // Map the words to ITerminalLink objects + for (const word of words) { + if (word.text === '') { + continue; + } + + if (word.text.length > 0 && word.text.charAt(word.text.length - 1) === ':') { + word.text = word.text.slice(0, -1); + word.endIndex--; + } + + const bufferRange = convertLinkRangeToBuffer( + lines, + this._xterm.cols, + { + startColumn: word.startIndex + 1, + startLineNumber: 1, + endColumn: word.endIndex + 1, + endLineNumber: 1, + }, + startLine, + ); + + // Search links + const activateHandler = (event: MouseEvent | undefined, text: string) => { + this._validationCallback(text, async (result) => { + if (result) { + if (result.isDirectory) { + this._handleLocalFolderLink(result.uri); + } else { + this._activateFileCallback(event, text); + } + } else { + this.quickOpenService.open(text); + } + }); + }; + + links.push( + this.injector.get(TerminalLink, [ + this._xterm, + bufferRange, + word.text, + this._xterm.buffer.active.viewportY, + activateHandler, + undefined, + true, + word.text, + ]), + ); + } + + return links; + } + + private async _handleLocalFolderLink(uri: URI): Promise { + // If the folder is within one of the window's workspaces, focus it in the explorer + if (await this._isDirectoryInsideWorkspace(uri)) { + await this.commandService.executeCommand('revealInExplorer', uri); + return; + } + + // Open a new window for the folder + if (this.appConfig.isElectronRenderer) { + this.windowService.openWorkspace(uri, { newWindow: true }); + } + } + + private _parseWords(text: string): Word[] { + const words: Word[] = []; + const splitWords = text.split(this._separatorRegex); + let runningIndex = 0; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < splitWords.length; i++) { + words.push({ + text: splitWords[i], + startIndex: runningIndex, + endIndex: runningIndex + splitWords[i].length, + }); + runningIndex += splitWords[i].length + 1; + } + return words; + } + + private async _isDirectoryInsideWorkspace(uri: URI) { + const folders = await this.workspaceService.roots; + for (const folder of folders) { + if (URI.parse(folder.uri).isEqualOrParent(uri)) { + return true; + } + } + return false; + } +} diff --git a/packages/terminal-next/src/browser/terminal.hover.manager.ts b/packages/terminal-next/src/browser/terminal.hover.manager.ts index e9455794f2..3d51e9e4d7 100644 --- a/packages/terminal-next/src/browser/terminal.hover.manager.ts +++ b/packages/terminal-next/src/browser/terminal.hover.manager.ts @@ -14,10 +14,12 @@ export class TerminalHoverManagerService implements ITerminalHoverManagerService private hoverWidget: HTMLElement | undefined; private appendTerminalHoverOverlay() { - const overlayContainer = document.querySelector('#ide-overlay'); + let overlayContainer = document.querySelector('#terminal-link-hover-overlay'); if (!overlayContainer) { - throw new Error('ide-overlay is requried'); + overlayContainer = document.createElement('div'); + overlayContainer.id = 'terminal-link-hover-overlay'; + document.body.appendChild(overlayContainer); } const overlay = document.createElement('div'); @@ -72,13 +74,11 @@ export class TerminalHoverManagerService implements ITerminalHoverManagerService this.hoverWidget.style.top = `${ (viewportRange.start.y - 1) * cellDimensions.height + boundingClientRect.y - TIPS_OFFSET_Y }px`; - let tooltipsLeft = viewportRange.start.x * cellDimensions.width + boundingClientRect.x + TIPS_OFFSET_X; // if the tooltip is too close to the right edge of the terminal, move it to the left if (tooltipsLeft + this.hoverWidget.clientWidth > boundingClientRect.x + boundingClientRect.width) { tooltipsLeft = boundingClientRect.x + boundingClientRect.width - this.hoverWidget.clientWidth - TIPS_OFFSET_X; } - this.hoverWidget.style.left = `${tooltipsLeft}px`; } }); diff --git a/packages/terminal-next/src/common/xterm-private.d.ts b/packages/terminal-next/src/common/xterm-private.d.ts index 169e39f948..f466bb4860 100644 --- a/packages/terminal-next/src/common/xterm-private.d.ts +++ b/packages/terminal-next/src/common/xterm-private.d.ts @@ -25,13 +25,16 @@ export interface XTermCore { _renderService: { dimensions: { - actualCellWidth: number; - actualCellHeight: number; + css: { + cell: { + width: number; + height: number; + }; + }; }; _renderer: { - _renderLayers: any[]; + value?: unknown; }; - _onIntersectionChange: any; }; } From dcaae394e10a0c2236ce55714fa5a93661657f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E8=A1=A8=E5=93=A5?= Date: Thu, 23 Jan 2025 13:52:23 +0800 Subject: [PATCH 3/3] fix: video preview style and static server (#4324) * fix: video preview style and static server * fix: video preview style and static server --- .../node/express-file-server.contribution.ts | 39 ++++++++++++++++++- .../file-scheme/src/browser/preview.view.tsx | 9 ++--- .../file-scheme/src/browser/style.module.less | 8 +++- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/express-file-server/src/node/express-file-server.contribution.ts b/packages/express-file-server/src/node/express-file-server.contribution.ts index 3e61866bcb..2bd7d56afe 100644 --- a/packages/express-file-server/src/node/express-file-server.contribution.ts +++ b/packages/express-file-server/src/node/express-file-server.contribution.ts @@ -44,12 +44,47 @@ export class ExpressFileServerContribution implements ServerAppContribution { // 在允许的 contentType contentType ) { - ctx.set('Content-Type', contentType); + const range = ctx.headers.range; + + if (!fs.existsSync(filePath)) { + ctx.status = 404; + ctx.body = '文件未找到'; + return; + } + if (this.appConfig.staticAllowOrigin) { ctx.set('Access-Control-Allow-Origin', this.appConfig.staticAllowOrigin); } - ctx.body = fs.createReadStream(filePath); + const stats = await fs.promises.stat(filePath); + const total = stats.size; + + if (!range) { + ctx.status = 200; + ctx.set('Content-Type', contentType); + ctx.set('Content-Length', String(total)); + ctx.body = fs.createReadStream(filePath); + return; + } + + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : total - 1; + + if (start >= total || end >= total || start > end) { + ctx.status = 416; // Range Not Satisfiable + ctx.set('Content-Range', `bytes */${total}`); + return; + } + + ctx.status = 206; + ctx.set('Content-Range', `bytes ${start}-${end}/${total}`); + ctx.set('Accept-Ranges', 'bytes'); + ctx.set('Content-Length', String(end - start + 1)); + ctx.set('Content-Type', contentType); + + const stream = fs.createReadStream(filePath, { start, end }); + ctx.body = stream; } else { ctx.status = 403; } diff --git a/packages/file-scheme/src/browser/preview.view.tsx b/packages/file-scheme/src/browser/preview.view.tsx index a48d290b0b..4ed7e0d581 100644 --- a/packages/file-scheme/src/browser/preview.view.tsx +++ b/packages/file-scheme/src/browser/preview.view.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import { Disposable, useInjectable } from '@opensumi/ide-core-browser'; import { StaticResourceService } from '@opensumi/ide-core-browser/lib/static-resource'; @@ -16,15 +16,14 @@ const useResource = (resource: IResource) => { }; }; -export const VideoPreview: ReactEditorComponent = (props) => { +export const VideoPreview: ReactEditorComponent = memo((props) => { const { src } = useResource(props.resource); - return (
- +
); -}; +}); export const ImagePreview: ReactEditorComponent = (props) => { const imgRef = React.useRef(); diff --git a/packages/file-scheme/src/browser/style.module.less b/packages/file-scheme/src/browser/style.module.less index c13edbc57f..284945ea7d 100644 --- a/packages/file-scheme/src/browser/style.module.less +++ b/packages/file-scheme/src/browser/style.module.less @@ -43,11 +43,15 @@ .kt_video_preview { height: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: 40px; } .kt_video { - width: 100%; - height: 100%; + max-width: 100%; + max-height: 100%; } .error-page {