src/dataEditor/dataEditorClient.ts (1,107 lines of code) (raw):
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ALL_EVENTS,
beginSessionTransaction,
clear,
countCharacters,
CountKind,
createSession,
createSimpleFileLogger,
createViewport,
del,
edit,
EditorClient,
endSessionTransaction,
EventSubscriptionRequest,
getByteOrderMark,
getClient,
getClientVersion,
getComputedFileSize,
getContentType,
getCounts,
getLanguage,
getLogger,
getServerInfo,
getViewportData,
IOFlags,
modifyViewport,
numAscii,
profileSession,
redo,
replaceOneSession,
saveSession,
SaveStatus,
searchSession,
setLogger,
startServer,
stopProcessUsingPID,
undo,
ViewportDataResponse,
ViewportEvent,
ViewportEventKind,
} from '@omega-edit/client'
import assert from 'assert'
import fs from 'fs'
import net from 'net'
import os from 'os'
import path from 'path'
import * as vscode from 'vscode'
import XDGAppPaths from 'xdg-app-paths'
import { extractDaffodilEvent } from '../daffodilDebugger/daffodil'
import {
EditByteModes,
VIEWPORT_CAPACITY_MAX,
} from '../svelte/src/stores/configuration'
import {
EditorMessage,
MessageCommand,
MessageLevel,
} from '../svelte/src/utilities/message'
import * as editor_config from './config'
import { configureOmegaEditPort, ServerInfo } from './include/server/ServerInfo'
import { isDFDLDebugSessionActive } from './include/utils'
import { SvelteWebviewInitializer } from './svelteWebviewInitializer'
import {
addActiveSession,
removeActiveSession,
} from './include/server/Sessions'
import { getCurrentHeartbeatInfo } from './include/server/heartbeat'
// *****************************************************************************
// global constants
// *****************************************************************************
export const DATA_EDITOR_COMMAND: string = 'extension.data.edit'
export const OMEGA_EDIT_HOST: string = '127.0.0.1'
export const SERVER_START_TIMEOUT: number = 15 // in seconds
export const APP_DATA_PATH: string = XDGAppPaths({ name: 'omega_edit' }).data()
// *****************************************************************************
// file-scoped constants
// *****************************************************************************
const HEARTBEAT_INTERVAL_MS: number = 1000 // 1 second (1000 ms)
const MAX_LOG_FILES: number = 5 // Maximum number of log files to keep TODO: make this configurable
// *****************************************************************************
// file-scoped variables
// *****************************************************************************
let serverInfo: ServerInfo = new ServerInfo()
let checkpointPath: string = ''
let client: EditorClient
let omegaEditPort: number = 0
// *****************************************************************************
// exported functions
// *****************************************************************************
export function activate(ctx: vscode.ExtensionContext): void {
ctx.subscriptions.push(
vscode.commands.registerCommand(
DATA_EDITOR_COMMAND,
async (fileToEdit: string = '') => {
let configVars = editor_config.extractConfigurationVariables()
return await createDataEditorWebviewPanel(ctx, configVars, fileToEdit)
}
)
)
}
// *****************************************************************************
// exported class
// *****************************************************************************
export class DataEditorClient implements vscode.Disposable {
public panel: vscode.WebviewPanel
private svelteWebviewInitializer: SvelteWebviewInitializer
private displayState: DisplayState
private currentViewportId: string
private fileToEdit: string = ''
private omegaSessionId = ''
private sendHeartbeatIntervalId: NodeJS.Timeout | number | undefined =
undefined
private disposables: vscode.Disposable[] = []
constructor(
protected context: vscode.ExtensionContext,
private view: string,
title: string,
private configVars: editor_config.IConfig,
fileToEdit: string = ''
) {
const column =
fileToEdit !== '' ? vscode.ViewColumn.Two : vscode.ViewColumn.Active
this.panel = vscode.window.createWebviewPanel(this.view, title, column, {
enableScripts: true,
retainContextWhenHidden: true,
})
this.panel.webview.onDidReceiveMessage(this.messageReceiver, this)
this.panel.onDidDispose(async () => {
await removeActiveSession(this.omegaSessionId)
await this.dispose()
})
this.disposables.push(
this.panel,
vscode.debug.onDidReceiveDebugSessionCustomEvent(async (e) => {
const debugEvent = e
const eventAsEditorMessage = extractDaffodilEvent(debugEvent)
if (eventAsEditorMessage === undefined) return
const forwardAs = eventAsEditorMessage.asObject()
await this.panel.webview.postMessage(forwardAs)
})
)
context.subscriptions.push(this)
this.svelteWebviewInitializer = new SvelteWebviewInitializer(context)
this.svelteWebviewInitializer.initialize(this.view, this.panel.webview)
this.currentViewportId = ''
this.fileToEdit = fileToEdit
this.displayState = new DisplayState(this.panel)
}
addDisposable(dispoable: vscode.Disposable) {
this.disposables.push(dispoable)
}
async dispose(): Promise<void> {
if (this.sendHeartbeatIntervalId) {
clearInterval(this.sendHeartbeatIntervalId)
this.sendHeartbeatIntervalId = undefined
}
for (let i = 0; i < this.disposables.length; i++)
this.disposables[i].dispose()
}
show(): void {
this.panel.reveal()
}
public async initialize() {
checkpointPath = this.configVars.checkpointPath
if (this.fileToEdit !== '') {
await this.setupDataEditor()
} else {
const fileUri = await vscode.window.showOpenDialog({
canSelectMany: false,
openLabel: 'Select',
canSelectFiles: true,
canSelectFolders: false,
})
if (fileUri && fileUri[0]) {
this.fileToEdit = fileUri[0].fsPath
this.panel.title = path.basename(this.fileToEdit)
await this.setupDataEditor()
}
}
// send and initial heartbeat, then send the heartbeat to the webview at regular intervals
await this.sendHeartbeat()
this.sendHeartbeatIntervalId = setInterval(() => {
this.sendHeartbeat()
}, HEARTBEAT_INTERVAL_MS)
}
sessionId(): string {
return this.omegaSessionId
}
private async setupDataEditor() {
assert(
checkpointPath && checkpointPath.length > 0,
'checkpointPath is not set'
)
let data = {
byteOrderMark: '',
changeCount: 0,
computedFileSize: 0,
diskFileSize: 0,
fileName: this.fileToEdit,
language: '',
type: '',
undoCount: 0,
}
// create a session and capture the session id, content type, and file size
try {
const createSessionResponse = await createSession(
this.fileToEdit,
undefined,
checkpointPath
)
this.omegaSessionId = createSessionResponse.getSessionId()
assert(this.omegaSessionId.length > 0, 'omegaSessionId is not set')
addActiveSession(this.omegaSessionId)
data.diskFileSize = data.computedFileSize =
createSessionResponse.hasFileSize()
? (createSessionResponse.getFileSize() as number)
: 0
const contentTypeResponse = await getContentType(
this.omegaSessionId,
0,
Math.min(1024, data.computedFileSize)
)
data.type = contentTypeResponse.getContentType()
assert(data.type.length > 0, 'contentType is not set')
const byteOrderMarkResponse = await getByteOrderMark(
this.omegaSessionId,
0
)
data.byteOrderMark = byteOrderMarkResponse.getByteOrderMark()
assert(data.byteOrderMark.length > 0, 'byteOrderMark is not set')
const languageResponse = await getLanguage(
this.omegaSessionId,
0,
Math.min(1024, data.computedFileSize),
data.byteOrderMark
)
data.language = languageResponse.getLanguage()
assert(data.language.length > 0, 'language is not set')
data.diskFileSize = data.computedFileSize =
createSessionResponse.hasFileSize()
? (createSessionResponse.getFileSize() as number)
: 0
} catch {
const msg = `Failed to create session for ${this.fileToEdit}`
getLogger().error({
err: {
msg: msg,
stack: new Error().stack,
},
})
vscode.window.showErrorMessage(msg)
}
// create the viewport
try {
const viewportDataResponse = await createViewport(
undefined,
this.omegaSessionId,
0,
VIEWPORT_CAPACITY_MAX,
false
)
this.currentViewportId = viewportDataResponse.getViewportId()
assert(this.currentViewportId.length > 0, 'currentViewportId is not set')
await viewportSubscribe(this.panel, this.currentViewportId)
await sendViewportRefresh(this.panel, viewportDataResponse)
} catch {
const msg = `Failed to create viewport for ${this.fileToEdit}`
getLogger().error({
err: {
msg: msg,
stack: new Error().stack,
},
})
vscode.window.showErrorMessage(msg)
}
// send the initial file info to the webview
await this.panel.webview.postMessage({
command: MessageCommand.fileInfo,
data: data,
})
}
private async sendHeartbeat() {
const heartbeatInfo = getCurrentHeartbeatInfo()
await this.panel.webview.postMessage({
command: MessageCommand.heartbeat,
data: {
latency: heartbeatInfo.latency,
omegaEditPort: this.configVars.port,
serverCpuLoadAverage: heartbeatInfo.serverCpuLoadAverage,
serverUptime: heartbeatInfo.serverUptime,
serverUsedMemory: heartbeatInfo.serverUsedMemory,
sessionCount: heartbeatInfo.sessionCount,
serverInfo: {
omegaEditPort: this.configVars.port,
serverVersion: serverInfo.serverVersion,
serverHostname: serverInfo.serverHostname,
serverProcessId: serverInfo.serverProcessId,
jvmVersion: serverInfo.jvmVersion,
jvmVendor: serverInfo.jvmVendor,
jvmPath: serverInfo.jvmPath,
availableProcessors: serverInfo.availableProcessors,
},
},
})
}
private async sendChangesInfo() {
// get the counts from the server
const counts = await getCounts(this.omegaSessionId, [
CountKind.COUNT_COMPUTED_FILE_SIZE,
CountKind.COUNT_CHANGE_TRANSACTIONS,
CountKind.COUNT_UNDO_TRANSACTIONS,
])
// accumulate the counts into a single object
let data = {
fileName: this.fileToEdit,
computedFileSize: 0,
changeCount: 0,
undoCount: 0,
}
counts.forEach((count) => {
switch (count.getKind()) {
case CountKind.COUNT_COMPUTED_FILE_SIZE:
data.computedFileSize = count.getCount()
break
case CountKind.COUNT_CHANGE_TRANSACTIONS:
data.changeCount = count.getCount()
break
case CountKind.COUNT_UNDO_TRANSACTIONS:
data.undoCount = count.getCount()
break
}
})
// send the accumulated counts to the webview
await this.panel.webview.postMessage({
command: MessageCommand.fileInfo,
data: data,
})
}
// handle messages from the webview
private async messageReceiver(message: EditorMessage) {
switch (message.command) {
case MessageCommand.showMessage:
switch (message.data.messageLevel as MessageLevel) {
case MessageLevel.Error:
vscode.window.showErrorMessage(message.data.message)
break
case MessageLevel.Info:
vscode.window.showInformationMessage(message.data.message)
break
case MessageLevel.Warn:
vscode.window.showWarningMessage(message.data.message)
break
}
break
case MessageCommand.scrollViewport:
await this.scrollViewport(
this.panel,
this.currentViewportId,
message.data.scrollOffset,
message.data.bytesPerRow
)
break
case MessageCommand.editorOnChange:
{
this.displayState.editorEncoding = message.data.encoding
const encodeDataAs =
message.data.editMode === EditByteModes.Single
? 'hex'
: this.displayState.editorEncoding
if (
message.data.selectionData &&
message.data.selectionData.length > 0
) {
await this.panel.webview.postMessage({
command: MessageCommand.editorOnChange,
display: dataToEncodedStr(
Buffer.from(message.data.selectionData),
encodeDataAs
),
})
}
}
break
case MessageCommand.applyChanges:
await edit(
this.omegaSessionId,
message.data.offset,
message.data.originalSegment,
message.data.editedSegment
)
await this.sendChangesInfo()
break
case MessageCommand.undoChange:
await undo(this.omegaSessionId)
await this.sendChangesInfo()
this.panel.webview.postMessage({
command: MessageCommand.clearChanges,
})
break
case MessageCommand.redoChange:
await redo(this.omegaSessionId)
await this.sendChangesInfo()
this.panel.webview.postMessage({
command: MessageCommand.clearChanges,
})
break
case MessageCommand.profile:
{
const startOffset: number = message.data.startOffset
const length: number = message.data.length
const byteProfile: number[] = await profileSession(
this.omegaSessionId,
startOffset,
length
)
const characterCount = await countCharacters(
this.omegaSessionId,
startOffset,
length
)
const contentTypeResponse = await getContentType(
this.omegaSessionId,
startOffset,
length
)
const languageResponse = await getLanguage(
this.omegaSessionId,
startOffset,
length,
characterCount.getByteOrderMark()
)
await this.panel.webview.postMessage({
command: MessageCommand.profile,
data: {
startOffset: startOffset,
length: length,
byteProfile: byteProfile,
numAscii: numAscii(byteProfile),
language: languageResponse.getLanguage(),
contentType: contentTypeResponse.getContentType(),
characterCount: {
byteOrderMark: characterCount.getByteOrderMark(),
byteOrderMarkBytes: characterCount.getByteOrderMarkBytes(),
singleByteCount: characterCount.getSingleByteChars(),
doubleByteCount: characterCount.getDoubleByteChars(),
tripleByteCount: characterCount.getTripleByteChars(),
quadByteCount: characterCount.getQuadByteChars(),
invalidBytes: characterCount.getInvalidBytes(),
},
},
})
}
break
case MessageCommand.clearChanges:
if (
(await vscode.window.showInformationMessage(
'Are you sure you want to revert all changes?',
{ modal: true },
'Yes',
'No'
)) === 'Yes'
) {
await clear(this.omegaSessionId)
await this.sendChangesInfo()
this.panel.webview.postMessage({
command: MessageCommand.clearChanges,
})
}
break
case MessageCommand.save:
await this.saveFile(this.fileToEdit)
break
case MessageCommand.saveAs:
{
const uri = await vscode.window.showSaveDialog({
title: 'Save Session',
saveLabel: 'Save',
})
if (uri && uri.fsPath) {
await this.saveFile(uri.fsPath)
}
}
break
case MessageCommand.saveSegment:
{
const uri = await vscode.window.showSaveDialog({
title: 'Save Segment',
saveLabel: 'Save',
})
if (uri && uri.fsPath) {
await this.saveFileSegment(
uri.fsPath,
message.data.offset,
message.data.length
)
}
}
break
case MessageCommand.requestEditedData:
{
const [selectionData, selectionDisplay] = fillRequestData(message)
await this.panel.webview.postMessage({
command: MessageCommand.requestEditedData,
data: {
data: Uint8Array.from(selectionData),
dataDisplay: selectionDisplay,
},
})
}
break
case MessageCommand.replace:
{
const searchDataBytes = encodedStrToData(
message.data.searchData,
message.data.encoding
)
const replaceDataBytes = encodedStrToData(
message.data.replaceData,
message.data.encoding
)
const nextOffset = await replaceOneSession(
this.omegaSessionId,
searchDataBytes,
replaceDataBytes,
message.data.caseInsensitive,
message.data.isReverse,
message.data.searchOffset,
message.data.searchLength,
message.data.overwriteOnly
)
if (nextOffset === -1) {
vscode.window.showErrorMessage('No replacement took place')
} else {
await this.sendChangesInfo()
}
await this.panel.webview.postMessage({
command: MessageCommand.replaceResults,
data: {
replacementsCount: nextOffset === -1 ? 0 : 1,
nextOffset: nextOffset,
searchDataBytesLength: searchDataBytes.length,
replaceDataBytesLength: replaceDataBytes.length,
},
})
}
break
case MessageCommand.search:
{
const searchDataBytes = encodedStrToData(
message.data.searchData,
message.data.encoding
)
const searchResults = await searchSession(
this.omegaSessionId,
searchDataBytes,
message.data.caseInsensitive,
message.data.isReverse,
message.data.searchOffset,
message.data.searchLength,
message.data.limit + 1
)
if (searchResults.length === 0) {
vscode.window.showInformationMessage(
`No more matches found for '${message.data.searchData}'`
)
}
let overflow = false
if (searchResults.length > message.data.limit) {
overflow = true
searchResults.pop()
}
await this.panel.webview.postMessage({
command: MessageCommand.searchResults,
data: {
searchResults: searchResults,
searchDataBytesLength: searchDataBytes.length,
overflow: overflow,
},
})
}
break
}
}
private async saveFileSegment(
fileToSave: string,
offset: number,
length: number
) {
// if the file to save is the same as the file being edited then we can save the file with a single transaction to
// trim the file to contain only the desired segment, preserving session state
if (this.fileToEdit === fileToSave) {
const computedFileSize = await getComputedFileSize(this.omegaSessionId)
if (offset === 0) {
if (offset + length !== computedFileSize) {
// delete from length to the end of the file
await del(this.omegaSessionId, length, computedFileSize - length)
await this.sendChangesInfo()
}
} else if (offset + length === computedFileSize) {
// delete from 0 to offset
await del(this.omegaSessionId, 0, offset)
await this.sendChangesInfo()
} else {
// delete from length to the end of the file and from 0 to offset in a single transaction
await beginSessionTransaction(this.omegaSessionId)
await del(
this.omegaSessionId,
offset + length,
computedFileSize - length
)
await del(this.omegaSessionId, 0, offset)
await endSessionTransaction(this.omegaSessionId)
await this.sendChangesInfo()
}
// save the segment to the file using the typical save method
await this.saveFile(fileToSave)
} else {
let saved = false
let cancelled = false
// try to save the file with overwrite
const saveResponse = await saveSession(
this.omegaSessionId,
fileToSave,
IOFlags.IO_FLG_OVERWRITE,
offset,
length
)
if (saveResponse.getSaveStatus() === SaveStatus.MODIFIED) {
// the file was modified since the session was created, query user to overwrite the modified file
if (
(await vscode.window.showInformationMessage(
'File has been modified since being opened overwrite the file anyway?',
{ modal: true },
'Yes',
'No'
)) === 'Yes'
) {
// the user decided to overwrite the file, try to save again with force overwrite
const saveResponse2 = await saveSession(
this.omegaSessionId,
fileToSave,
IOFlags.IO_FLG_FORCE_OVERWRITE,
offset,
length
)
saved = saveResponse2.getSaveStatus() === SaveStatus.SUCCESS
} else {
cancelled = true
}
} else {
saved = saveResponse.getSaveStatus() === SaveStatus.SUCCESS
}
if (saved) {
vscode.window.showInformationMessage(`Saved: ${fileToSave}`)
} else if (cancelled) {
vscode.window.showInformationMessage(`Cancelled save: ${fileToSave}`)
} else {
vscode.window.showErrorMessage(`Failed to save: ${fileToSave}`)
}
}
}
private async saveFile(fileToSave: string) {
let saved = false
let cancelled = false
// try to save the file with overwrite
const saveResponse = await saveSession(
this.omegaSessionId,
fileToSave,
IOFlags.IO_FLG_OVERWRITE
)
if (saveResponse.getSaveStatus() === SaveStatus.MODIFIED) {
// the file was modified since the session was created, query user to overwrite the modified file
if (
(await vscode.window.showInformationMessage(
'File has been modified since being opened overwrite the file anyway?',
{ modal: true },
'Yes',
'No'
)) === 'Yes'
) {
// the user decided to overwrite the file, try to save again with force overwrite
const saveResponse2 = await saveSession(
this.omegaSessionId,
fileToSave,
IOFlags.IO_FLG_FORCE_OVERWRITE
)
saved = saveResponse2.getSaveStatus() === SaveStatus.SUCCESS
} else {
cancelled = true
}
} else {
saved = saveResponse.getSaveStatus() === SaveStatus.SUCCESS
}
if (saved) {
this.fileToEdit = fileToSave
const fileSize = await getComputedFileSize(this.omegaSessionId)
await this.panel.webview.postMessage({
command: MessageCommand.fileInfo,
data: {
computedFileSize: fileSize,
diskFileSize: fileSize,
fileName: fileToSave,
},
})
vscode.window.showInformationMessage(`Saved: ${fileToSave}`)
} else if (cancelled) {
vscode.window.showInformationMessage(`Cancelled save: ${fileToSave}`)
} else {
vscode.window.showErrorMessage(`Failed to save: ${fileToSave}`)
}
}
private async scrollViewport(
panel: vscode.WebviewPanel,
viewportId: string,
offset: number,
bytesPerRow: number
) {
// start of the row containing the offset, making sure the offset is never negative
const startOffset = Math.max(0, offset - (offset % bytesPerRow))
try {
await sendViewportRefresh(
panel,
await modifyViewport(viewportId, startOffset, VIEWPORT_CAPACITY_MAX)
)
} catch {
const msg = `Failed to scroll viewport ${viewportId} to offset ${startOffset}`
getLogger().error({
err: {
msg: msg,
stack: new Error().stack,
},
})
vscode.window.showErrorMessage(msg)
}
}
}
// *****************************************************************************
// file-scoped functions
// *****************************************************************************
async function createDataEditorWebviewPanel(
ctx: vscode.ExtensionContext,
launchConfigVars: editor_config.IConfig,
fileToEdit: string
): Promise<DataEditorClient> {
// make sure the app data path exists
fs.mkdirSync(APP_DATA_PATH, { recursive: true })
assert(fs.existsSync(APP_DATA_PATH), 'app data path does not exist')
// make sure the omega edit port is configured
configureOmegaEditPort(launchConfigVars)
omegaEditPort = launchConfigVars.port
// only start up the server if one is not already running
if (!(await checkServerListening(omegaEditPort, OMEGA_EDIT_HOST))) {
await setupLogging(launchConfigVars)
await serverStart()
client = await getClient(omegaEditPort, OMEGA_EDIT_HOST)
assert(
await checkServerListening(omegaEditPort, OMEGA_EDIT_HOST),
'server not listening'
)
}
fileToEdit = fileToEdit.replace(
editor_config.WorkspaceKeyword,
editor_config.rootPath
)
const dataEditorView = new DataEditorClient(
ctx,
'dataEditor',
'Data Editor',
launchConfigVars,
fileToEdit
)
await dataEditorView.initialize()
if (isDFDLDebugSessionActive())
dataEditorView.addDisposable(
vscode.debug.onDidTerminateDebugSession(async () => {
if (dataEditorView) await dataEditorView.dispose()
})
)
dataEditorView.show()
return dataEditorView
}
function rotateLogFiles(logFile: string): void {
interface LogFile {
path: string
ctime: Date
}
assert(
MAX_LOG_FILES > 0,
'Maximum number of log files must be greater than 0'
)
if (fs.existsSync(logFile)) {
const logDir = path.dirname(logFile)
const logFileName = path.basename(logFile)
// Get list of existing log files
const logFiles: LogFile[] = fs
.readdirSync(logDir)
.filter((file) => file.startsWith(logFileName) && file !== logFileName)
.map((file) => ({
path: path.join(logDir, file),
ctime: fs.statSync(path.join(logDir, file)).ctime,
}))
.sort((a, b) => b.ctime.getTime() - a.ctime.getTime())
// Delete oldest log files if maximum number of log files is exceeded
while (logFiles.length >= MAX_LOG_FILES) {
const fileToDelete = logFiles.pop() as LogFile
fs.unlinkSync(fileToDelete.path)
}
// Rename current log file with timestamp and create a new empty file
const timestamp = new Date().toISOString().replace(/:/g, '-')
fs.renameSync(logFile, path.join(logDir, `${logFileName}.${timestamp}`))
}
}
function getPidFile(serverPort: number): string {
return path.join(APP_DATA_PATH, `serv-${serverPort}.pid`)
}
async function setupLogging(configVars: editor_config.Config): Promise<void> {
const logFile = configVars.logFile
const logLevel =
process.env.OMEGA_EDIT_CLIENT_LOG_LEVEL ||
process.env.OMEGA_EDIT_LOG_LEVEL ||
configVars.logLevel
rotateLogFiles(logFile)
setLogger(createSimpleFileLogger(logFile, logLevel))
vscode.window.showInformationMessage(`Logging (${logLevel}) to '${logFile}'`)
}
async function sendViewportRefresh(
panel: vscode.WebviewPanel,
viewportDataResponse: ViewportDataResponse
): Promise<void> {
await panel.webview.postMessage({
command: MessageCommand.viewportRefresh,
data: {
viewportId: viewportDataResponse.getViewportId(),
viewportOffset: viewportDataResponse.getOffset(),
viewportLength: viewportDataResponse.getLength(),
viewportFollowingByteCount: viewportDataResponse.getFollowingByteCount(),
viewportData: viewportDataResponse.getData_asU8(),
viewportCapacity: VIEWPORT_CAPACITY_MAX,
},
})
}
/**
* Subscribe to all events for a given viewport so the editor gets refreshed when changes to the viewport occur
* @param panel webview panel to send updates to
* @param viewportId id of the viewport to subscribe to
*/
async function viewportSubscribe(
panel: vscode.WebviewPanel,
viewportId: string
) {
// subscribe to all viewport events
client
.subscribeToViewportEvents(
new EventSubscriptionRequest()
.setId(viewportId)
.setInterest(ALL_EVENTS & ~ViewportEventKind.VIEWPORT_EVT_MODIFY)
)
.on('data', async (event: ViewportEvent) => {
getLogger().debug({
viewportId: event.getViewportId(),
event: event.getViewportEventKind(),
})
await sendViewportRefresh(panel, await getViewportData(viewportId))
})
.on('error', (err) => {
// Call cancelled thrown sometimes when server is shutdown
if (
!err.message.includes('Call cancelled') &&
!err.message.includes('UNAVAILABLE')
)
throw err
})
}
class DisplayState {
public editorEncoding: BufferEncoding
public colorThemeKind: vscode.ColorThemeKind
private panel: vscode.WebviewPanel
constructor(editorPanel: vscode.WebviewPanel) {
this.editorEncoding = 'hex'
this.colorThemeKind = vscode.window.activeColorTheme.kind
this.panel = editorPanel
vscode.window.onDidChangeActiveColorTheme(async (event) => {
this.colorThemeKind = event.kind
await this.sendUIThemeUpdate()
})
this.sendUIThemeUpdate()
}
private sendUIThemeUpdate() {
return this.panel.webview.postMessage({
command: MessageCommand.setUITheme,
theme: this.colorThemeKind,
})
}
}
function fillRequestData(message: EditorMessage): [Buffer, string] {
let selectionByteData: Buffer
let selectionByteDisplay: string
if (message.data.editMode === EditByteModes.Multiple) {
selectionByteData = encodedStrToData(
message.data.editedContent,
message.data.encoding
)
selectionByteDisplay = dataToEncodedStr(
selectionByteData,
message.data.encoding
)
} else {
selectionByteData =
message.data.viewport === 'logical'
? encodedStrToData(message.data.editedContent, 'latin1')
: Buffer.from([
parseInt(message.data.editedContent, message.data.radix),
])
selectionByteDisplay =
message.data.viewport === 'logical'
? message.data.editedContent
: dataToRadixStr(selectionByteData, message.data.radix)
}
return [selectionByteData, selectionByteDisplay]
}
function encodedStrToData(
selectionEdits: string,
selectionEncoding?: BufferEncoding
): Buffer {
let selectionByteData: Buffer
switch (selectionEncoding) {
case 'hex':
selectionByteData = Buffer.alloc(selectionEdits.length / 2)
for (let i = 0; i < selectionEdits.length; i += 2) {
selectionByteData[i / 2] = parseInt(selectionEdits.slice(i, i + 2), 16)
}
return selectionByteData
case 'binary':
selectionByteData = Buffer.alloc(selectionEdits.length / 8)
for (let i = 0; i < selectionEdits.length; i += 8) {
selectionByteData[i / 8] = parseInt(selectionEdits.slice(i, i + 8), 2)
}
return selectionByteData
default:
return Buffer.from(selectionEdits, selectionEncoding)
}
}
function dataToEncodedStr(buffer: Buffer, encoding: BufferEncoding): string {
return encoding === 'binary'
? dataToRadixStr(buffer, 2)
: buffer.toString(encoding)
}
function dataToRadixStr(buffer: Buffer, radix: number): string {
const padLen = radixBytePad(radix)
let ret = ''
for (let i = 0; i < buffer.byteLength; i++) {
ret += buffer[i].toString(radix).padStart(padLen, '0')
}
return ret
}
function radixBytePad(radix: number): number {
switch (radix) {
case 2:
return 8
case 8:
return 3
case 10:
return 3
case 16:
return 2
}
return 0
}
/**
* Checks if a server is listening on a given port and host
* @param port port to check
* @param host host to check
* @returns true if a server is listening on the given port and host, false otherwise
*/
function checkServerListening(port: number, host: string): Promise<boolean> {
return new Promise((resolve) => {
const socket: net.Socket = new net.Socket()
socket.setTimeout(2000) // set a 2-second timeout for the connection attempt
socket.on('connect', () => {
socket.destroy() // close the connection once connected
resolve(true) // server is listening
})
socket.on('timeout', () => {
socket.destroy() // close the connection on timeout
resolve(false) // server is not listening
})
socket.on('error', () => {
resolve(false) // server is not listening or an error occurred
})
socket.connect(port, host)
})
}
/**
* Removes a directory and all of its contents
* @param dirPath path to directory to remove
*/
function removeDirectory(dirPath: string): void {
if (fs.existsSync(dirPath)) {
fs.readdirSync(dirPath).forEach((file) => {
const curPath = `${dirPath}/${file}`
if (fs.lstatSync(curPath).isDirectory()) {
// Recursively remove subdirectories
removeDirectory(curPath)
} else {
// Delete file
fs.unlinkSync(curPath)
}
})
// Remove empty directory
fs.rmdirSync(dirPath)
}
}
export async function serverStop() {
const serverPidFile = getPidFile(omegaEditPort)
if (fs.existsSync(serverPidFile)) {
const pid = parseInt(fs.readFileSync(serverPidFile).toString())
if (await stopProcessUsingPID(pid)) {
vscode.window.setStatusBarMessage(
`Ωedit server stopped on port ${omegaEditPort} with PID ${pid}`,
new Promise((resolve) => {
setTimeout(() => {
resolve(true)
}, 4000)
})
)
removeDirectory(checkpointPath)
} else {
// Check again if the process has stopped after a short delay
await new Promise((resolve) => setTimeout(resolve, 500))
if (!(await stopProcessUsingPID(pid))) {
vscode.window.showErrorMessage(
`Ωedit server on port ${omegaEditPort} with PID ${pid} failed to stop`
)
} else {
vscode.window.setStatusBarMessage(
`Ωedit server stopped on port ${omegaEditPort} with PID ${pid}`,
new Promise((resolve) => {
setTimeout(() => {
resolve(true)
}, 4000)
})
)
removeDirectory(checkpointPath)
}
}
}
}
function generateLogbackConfigFile(
logFile: string,
logLevel: string = 'INFO'
): string {
const dirname = path.dirname(logFile)
if (!fs.existsSync(dirname)) {
fs.mkdirSync(dirname, { recursive: true })
}
logLevel = logLevel.toUpperCase()
const logbackConfig = `<?xml version="1.0" encoding="UTF-8"?>\n
<configuration>
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${logFile}</file>
<encoder>
<pattern>[%date{ISO8601}] [%level] [%logger] [%marker] [%thread] - %msg MDC: {%mdc}%n</pattern>
</encoder>
</appender>
<root level="${logLevel}">
<appender-ref ref="FILE" />
</root>
</configuration>
`
const logbackConfigFile = path.join(
APP_DATA_PATH,
`serv-${omegaEditPort}.logconf.xml`
)
rotateLogFiles(logFile)
fs.writeFileSync(logbackConfigFile, logbackConfig)
return logbackConfigFile // Return the path to the logback config file
}
async function serverStart() {
await serverStop()
const serverStartingText = `Ωedit server starting on port ${omegaEditPort}`
const statusBarItem = vscode.window.createStatusBarItem(
vscode.StatusBarAlignment.Left
)
statusBarItem.text = serverStartingText
statusBarItem.show()
let animationFrame = 0
const animationInterval = 400 // ms per frame
const animationFrames = ['', '.', '..', '...']
const animationIntervalId = setInterval(() => {
statusBarItem.text = `${serverStartingText} ${
animationFrames[++animationFrame % animationFrames.length]
}`
}, animationInterval)
const config = vscode.workspace.getConfiguration('dataEditor')
const logLevel =
process.env.OMEGA_EDIT_SERVER_LOG_LEVEL ||
process.env.OMEGA_EDIT_LOG_LEVEL ||
config.get<string>('logLevel', 'info')
const logConfigFile = generateLogbackConfigFile(
path.join(APP_DATA_PATH, `serv-${omegaEditPort}.log`),
logLevel
)
if (!fs.existsSync(logConfigFile)) {
clearInterval(animationIntervalId)
statusBarItem.dispose()
throw new Error(`Log config file '${logConfigFile}' not found`)
}
// Start the server and wait up to 10 seconds for it to start
const serverPid = (await Promise.race([
startServer(
omegaEditPort,
OMEGA_EDIT_HOST,
getPidFile(omegaEditPort),
logConfigFile
),
new Promise((_resolve, reject) => {
setTimeout(() => {
reject((): Error => {
return new Error(
`Server startup timed out after ${SERVER_START_TIMEOUT} seconds`
)
})
}, SERVER_START_TIMEOUT * 1000)
}),
])) as number | undefined
clearInterval(animationIntervalId)
if (serverPid === undefined || serverPid <= 0) {
statusBarItem.dispose()
throw new Error('Server failed to start or PID is invalid')
}
// this makes sure the server if fully online and ready to take requests
statusBarItem.text = `Initializing Ωedit server on port ${omegaEditPort}`
for (let i = 1; i <= 60; ++i) {
try {
await getServerInfo()
break
} catch (err) {
statusBarItem.text = `Initializing Ωedit server on port ${omegaEditPort} (${i}/60)`
}
// wait 1 second before trying again
await new Promise((resolve) => {
setTimeout(() => {
resolve(true)
}, 1000)
})
}
try {
serverInfo = await getServerInfo()
} catch (err) {
statusBarItem.dispose()
await serverStop()
throw new Error('Server failed to initialize')
}
statusBarItem.text = `Ωedit server on port ${omegaEditPort} initialized`
const serverVersion = serverInfo.serverVersion
// if the OS is not Windows, check that the server PID matches the one started
// NOTE: serverPid is the PID of the server wrapper script on Windows
if (
!os.platform().toLowerCase().startsWith('win') &&
serverInfo.serverProcessId !== serverPid
) {
statusBarItem.dispose()
throw new Error(
`server PID mismatch ${serverInfo.serverProcessId} != ${serverPid}`
)
}
const clientVersion = getClientVersion()
if (serverVersion !== clientVersion) {
statusBarItem.dispose()
throw new Error(
`Server version ${serverVersion} and client version ${clientVersion} must match`
)
}
statusBarItem.text = `Ωedit server v${serverVersion} ready on port ${omegaEditPort} with PID ${serverInfo.serverProcessId}`
setTimeout(() => {
statusBarItem.dispose()
}, 5000)
}