Skip to content

Commit

Permalink
Improvements on project item references (#206)
Browse files Browse the repository at this point in the history
* store gitUri to project item

* automatically update line numbers for todo

* consolify references in react component

* add unit tests

* add some e2e tests

* more e2e tests

* remove debug

* fix unit tests

* use levenshtein for detecting location of item

* scroll todo item link into view

* fix package.json

* fix implementation

* scroll item into viewport

* skip page item tests for web

* improve e2e tests

* wait for widgets to be displayed

* wait after enter

* try a different command

* open Marquee by clicking on the action button
  • Loading branch information
christian-bromann authored Aug 30, 2022
1 parent 756764e commit ad3bdfc
Show file tree
Hide file tree
Showing 52 changed files with 979 additions and 148 deletions.
17 changes: 12 additions & 5 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Config } from '@jest/types';
import type { InitialOptionsTsJest } from 'ts-jest'
import { jsWithTsESM as tsjPreset } from 'ts-jest/presets';

const config: Config.InitialOptions = {
const config: InitialOptionsTsJest = {
preset: 'ts-jest',
testEnvironment: 'jest-environment-jsdom',
testMatch: [
Expand All @@ -28,15 +28,22 @@ const config: Config.InitialOptions = {
],
coverageThreshold: {
global: {
branches: 57,
functions: 58,
branches: 58,
functions: 59,
lines: 75,
statements: 74
}
},
restoreMocks: true,
setupFilesAfterEnv: ['<rootDir>/jest-setup.ts'],
bail: false
bail: false,
globals: {
'ts-jest': {
diagnostics: {
exclude: ['!**/*.(spec|test).ts?(x)'],
}
}
}
};

export default config;
4 changes: 2 additions & 2 deletions packages/extension/src/gui.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,8 @@ export class MarqueeGui extends EventEmitter {
/**
* the extension properly exports a setup method
*/
extension.exports &&
extension.exports.marquee &&
extension.exports &&
extension.exports.marquee &&
typeof extension.exports.marquee.setup === 'function'
) {
const defaultState = extension.exports.marquee?.disposable?.state || {}
Expand Down
3 changes: 2 additions & 1 deletion packages/extension/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export function activateGUI (
}

export const linkMarquee = async (item: any) => {
let file = item?.item?.origin
let file = item?.item?.path

if (!file) {
return
Expand Down Expand Up @@ -149,6 +149,7 @@ export const linkMarquee = async (item: any) => {
}

editor.revealRange(r, vscode.TextEditorRevealType.InCenter)
editor.selection = new vscode.Selection(r.start, r.end)
} catch (err: any) {
console.warn(`Marquee: ${(err as Error).message}`)
}
Expand Down
6 changes: 3 additions & 3 deletions packages/extension/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,16 @@ test('extension manager listens on mode changes and applies if not triggered wit

test('linkMarquee', async () => {
const parse = vscode.Uri.parse as jest.Mock
await linkMarquee({ item: { origin: '/some/file:123:3' } })
await linkMarquee({ item: { path: '/some/file:123:3' } })
expect(parse).toBeCalledWith('/some/file:123')
await linkMarquee({ item: { origin: '/some/file:124' } })
await linkMarquee({ item: { path: '/some/file:124' } })
expect(parse).toBeCalledWith('/some/file')

// @ts-expect-error mock feature
vscode.workspace.lineAtMock.mockImplementation(() => {
throw new Error('ups')
})
const logSpy = jest.spyOn(console, 'warn')
await linkMarquee({ item: { origin: '/some/file:124' } })
await linkMarquee({ item: { path: '/some/file:124' } })
expect(logSpy).toBeCalledWith('Marquee: ups')
})
1 change: 1 addition & 0 deletions packages/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"@types/lodash.pick": "^4.4.6",
"@types/object-hash": "^2.2.1",
"@types/uuid": "^8.3.4",
"fastest-levenshtein": "^1.0.16",
"hex-rgb": "4.3.0",
"lodash.pick": "^4.4.0",
"object-hash": "^3.0.0",
Expand Down
139 changes: 139 additions & 0 deletions packages/utils/src/components/ProjectItemLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React, { useMemo } from 'react'
import LinkIcon from '@mui/icons-material/Link'
import Tooltip from '@mui/material/Tooltip'
import { Link, Button, Typography, IconButton } from '@mui/material'

import { jumpTo } from '../index'
import type { ProjectItem, MarqueeWindow } from '../types'

const marqueeWindow: MarqueeWindow = window as any

const transformPathToLink = (item: ProjectItem) => {
try {
// transform
// -> [email protected]/foo/bar.git#main
// to
// -> https://github.com/foo/bar/blob/adcbe2a5c0428783fe9d6b50a1d2e39cbbe2def6/some/file#L3
const [path, line] = item.origin!.split(':')
const u = new window.URL(`https://${item.gitUri!
.replace(':', '/')
.split('@')
.pop()!
}`)
const rPath = path.replace(marqueeWindow.activeWorkspace!.path, '')
return `${u.origin}${u.pathname.replace(/\.git$/, '')}/blob/${item.commit}${rPath}#L${parseInt(line, 10) + 1}`
} catch (err: any) {
console.warn(`Couldn't construct remote url: ${(err as Error).message}`)
return undefined
}
}

interface ProjectItemLinkParams {
item?: ProjectItem
iconOnly?: boolean
}

let ProjectItemLink = (props: ProjectItemLinkParams) => {
if (!props.item) {
return <></>
}

const itemLinkName = useMemo(() => {
const path = props.item?.path || props.item?.origin || 'unknown:unknown'
const [fileName, line] = path.split('/').pop()!.split(':')
return `${fileName}:${parseInt(line, 10) + 1}`
}, [props.item])

const link = useMemo(() => {
if (props.item?.path) {
return props.item?.path
}
const useRemoteLink = props.item?.gitUri && props.item?.commit && props.item?.origin
if (useRemoteLink && marqueeWindow.activeWorkspace) {
return transformPathToLink(props.item!)
}
}, [props.item])

if (props.item.path) {
if (props.iconOnly) {
return (
<Tooltip
style={{borderRadius: '4px'}}
title={<Typography variant="subtitle2">{props.item.path}</Typography>}
placement="top"
arrow
>
<Typography variant="body2" noWrap>
<IconButton aria-label="Project Item Link" size="small" tabIndex={-1} onClick={() => jumpTo(props.item)}>
<LinkIcon />
</IconButton>
</Typography>
</Tooltip>
)
}

return (
<Button
aria-label="Project Item Link"
size="small"
startIcon={<LinkIcon />}
disableFocusRipple
onClick={() => jumpTo(props.item)}
style={{
padding: '0 5px',
background: 'transparent',
color: 'inherit'
}}
>
{itemLinkName}
</Button>
)
}

if (!link) {
return <></>
}

if (props.iconOnly) {
return (
<Tooltip
style={{borderRadius: '4px', marginLeft: '5px'}}
title={<Typography variant="subtitle2">{link}</Typography>}
placement="top"
arrow
>
<Typography variant="body2" noWrap>
<Link aria-label="Project Item Link" tabIndex={-1} href={link}>
<LinkIcon />
</Link>
</Typography>
</Tooltip>
)
}

return (
<Link
aria-label="Project Item Link"
href={link}
style={{
padding: '3px 5px 0 29px',
background: 'transparent',
color: 'inherit',
fontSize: '.81em',
textDecoration: 'none',
position: 'relative',
display: 'block'
}}
>
<LinkIcon style={{
fontSize: '18px',
position: 'absolute',
top: '2px',
left: '3px'
}} />
{itemLinkName}
</Link>
)
}

export default React.memo(ProjectItemLink)
21 changes: 21 additions & 0 deletions packages/utils/src/components/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { MarqueeWindow } from '../types'

declare const window: MarqueeWindow

/**
* open the reference of a project item
* @param item project item (note/snippet/todo)
* @param w window object (for testing purposes only)
*/
export const jumpTo = (item: any, w = window) => {
w.vscode.postMessage({
west: {
execCommands: [
{
command: 'marquee.link',
args: [{ item }],
},
],
},
})
}
104 changes: 102 additions & 2 deletions packages/utils/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import hash from 'object-hash'
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid'
import { Client } from 'tangle'
import { EventEmitter } from 'events'
import { closest } from 'fastest-levenshtein'

import { GitProvider } from './provider/git'
import { DEFAULT_CONFIGURATION, DEFAULT_STATE, DEPRECATED_GLOBAL_STORE_KEY, EXTENSION_ID, pkg } from './constants'
import { WorkspaceType } from './types'
import type { Configuration, State, Workspace } from './types'
import { WorkspaceType, ProjectItem } from './types'
import type { Configuration, State, Workspace, ProjectItemTypes } from './types'

const NAMESPACE = '144fb8a8-7dbf-4241-8795-0dc12b8e2fb6'
const CONFIGURATION_TARGET = vscode.ConfigurationTarget.Global
Expand Down Expand Up @@ -278,6 +279,104 @@ export default class ExtensionManager<State, Configuration> extends EventEmitter
return uuidv4()
}

registerFileListenerForFile (itemName: ProjectItemTypes, file: string) {
this._channel.appendLine(`Register File Listener for ${itemName} for file "${file}"`)
const listener = vscode.workspace.createFileSystemWatcher(file)
listener.onDidChange(this._onFileChange.bind(this, itemName) as any)
return listener
}

getItemsWithReference (itemName: ProjectItemTypes): ProjectItem[] {
const ws = this.getActiveWorkspace()
return (this.state[itemName as keyof State] as any as Array<ProjectItem>)
// only watch files that have todos in current workspace
.filter((t) => Boolean(t.path) && ws && ws.id === t.workspaceId)
}

protected async _onFileChange (itemName: ProjectItemTypes, uri: vscode.Uri) {
const content = (await vscode.workspace.fs.readFile(uri)).toString().split('\n')
const itemsInFile = this.getItemsWithReference(itemName).filter((t) => uri.path.endsWith(t.path!.split(':')[0]))

this._channel.appendLine(`Found ${itemsInFile.length} ${itemName} connected to updated file`)
for (const item of itemsInFile) {
const lineNumber = parseInt(item.path!.split(':').pop()!, 10)

/**
* notes have markdown support and might start with <p>
*/
const itemBody = item.body.split('\n')[0]
const itemBodyParsed = itemBody.startsWith('<p>')
? itemBody.endsWith('</p>')
? itemBody.slice('<p>'.length, -('</p>'.length))
: itemBody.slice('<p>'.length)
: itemBody

/**
* check if we still can find the reference
*/
if (typeof content[lineNumber] === 'string' && content[lineNumber].includes(itemBodyParsed)) {
this._channel.appendLine(`item with id ${item.id} does not need to be updated`)
continue
}

/**
* in order to pick the next close code line from the previous position we need
* to reorder the content from, given content is [a, b, c, d, e]) and c is our
* previous code line, to [c, b, d, a, e]
*/
const linesToItem = [...new Array(lineNumber)].map((_, i) => lineNumber - (i + 1))
const linesFromItem = [...new Array(Math.max(content.length - lineNumber, 0))].map((_, i) => (i + 1) + lineNumber)
const contentReordered = (
linesToItem.length >= linesFromItem.length ? linesToItem : linesFromItem
).reduce((prev, curr, i) => {
if (typeof linesToItem[i] === 'number') {
prev.add(linesToItem[i])
}
if (typeof linesFromItem[i] === 'number') {
prev.add(linesFromItem[i])
}
return prev
}, new Set<number>([lineNumber]))

const newLine = content.findIndex(
(l) => l === closest(
itemBodyParsed,
[...contentReordered]
.map((l) => content[l])
.filter((c) => typeof c === 'string')
)
)

this._updateReference(itemName, item.id, newLine)
}
}

private _updateReference (itemName: ProjectItemTypes, id: string, newLine?: number) {
const items = this.state[itemName as keyof State] as any as ProjectItem[]
const otherItems = items.filter((t) => t.id !== id)
const modifiedItem = items.find((t) => t.id === id)

if (!modifiedItem || !modifiedItem.path) {
return
}

if (newLine) {
const uri = modifiedItem.path.split(':')[0]
modifiedItem.path = `${uri}:${newLine}`
this._channel.appendLine(
`Updated path of ${itemName.slice(0, -1)} item with id ${modifiedItem.id}, new path is ${modifiedItem.path}`
)
} else {
delete modifiedItem.path
this._channel.appendLine(
`Can't find original reference for ${itemName.slice(0, -1)} with id ${modifiedItem.id}, removing its path`
)
}

this._state[itemName as keyof State] = [modifiedItem, ...otherItems] as any as State[keyof State]
return this.emitStateUpdate(true)
}

reset () {
if (this._tangle) {
this._subscriptions.forEach((s) => s.unsubscribe())
Expand All @@ -300,6 +399,7 @@ export class GlobalExtensionManager extends ExtensionManager<State, Configuratio
this._gitProvider?.on('stateUpdate', (provider) => {
this.updateState('branch', provider.branch, true)
this.updateState('commit', provider.commit, true)
this.updateState('gitUri', provider.gitUri, true)
})
}
}
Expand Down
Loading

0 comments on commit ad3bdfc

Please sign in to comment.