Skip to content
This repository was archived by the owner on Jul 30, 2025. It is now read-only.

Commit 03e3f8a

Browse files
myan9starpit
authored andcommitted
feat: head command against vfs
Fixes #7012
1 parent 223a405 commit 03e3f8a

File tree

9 files changed

+319
-2
lines changed

9 files changed

+319
-2
lines changed

packages/test/src/api/selectors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export const SIDECAR_TAB_CONTENT = (N: number, splitIndex = 1) =>
8686
`${SIDECAR(N, splitIndex)} .kui--tab-content:not([hidden]) .custom-content`
8787
export const SIDECAR_CUSTOM_CONTENT = (N: number, splitIndex = 1) =>
8888
`${SIDECAR_TAB_CONTENT(N, splitIndex)} .code-highlighting`
89+
export const SIDECAR_CUSTOM_CONTENT_LINE_NUMBERS = (N: number, splitIndex = 1) =>
90+
`${SIDECAR_TAB_CONTENT(N, splitIndex)} .code-highlighting .line-numbers`
8991

9092
// top nav sidecar
9193
export const SIDECAR_MODE_BUTTONS = (N: number, splitIndex = 1) =>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* Copyright 2021 IBM Corporation
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import Debug from 'debug'
18+
import { basename, dirname } from 'path'
19+
20+
import {
21+
findFile,
22+
expandHomeDir,
23+
i18n,
24+
MultiModalResponse,
25+
Arguments,
26+
Registrar,
27+
KResponse,
28+
ParsedOptions
29+
} from '@kui-shell/core'
30+
import File from './File'
31+
import { contentTypeOf } from './open'
32+
import { fslice } from '../vfs/delegates'
33+
34+
const strings = i18n('plugin-bash-like')
35+
const debug = Debug('plugins/bash-like/cmds/head')
36+
37+
interface HeadOptions extends ParsedOptions {
38+
n: number // line counts
39+
c: number // bytes
40+
}
41+
42+
export function showResponseAsMMR(filepath: string, data: string): MultiModalResponse {
43+
const suffix = filepath.substring(filepath.lastIndexOf('.') + 1)
44+
const enclosingDirectory = dirname(filepath)
45+
const packageName = enclosingDirectory === '.' ? undefined : enclosingDirectory
46+
47+
const mode = {
48+
mode: 'view',
49+
label: strings('View'),
50+
contentType: contentTypeOf(suffix),
51+
content: data
52+
}
53+
54+
const response: MultiModalResponse<File> = {
55+
apiVersion: 'kui-shell/v1',
56+
kind: 'File',
57+
metadata: {
58+
name: basename(filepath),
59+
namespace: packageName
60+
},
61+
modes: [mode],
62+
spec: {
63+
filepath,
64+
fullpath: findFile(expandHomeDir(filepath))
65+
}
66+
}
67+
68+
return response
69+
}
70+
71+
async function head(args: Arguments<HeadOptions>): Promise<KResponse> {
72+
const { argvNoOptions, parsedOptions } = args
73+
const filepath = argvNoOptions[argvNoOptions.indexOf('head') + 1]
74+
debug('head', filepath)
75+
76+
if (typeof filepath === 'number' && filepath < 0) {
77+
// special case for command: e.g. head -10 file
78+
// change `head -10 file` to `head -n 10 file`
79+
const newarg = `-n ${filepath * -1}`
80+
return args.REPL.qexec(args.command.replace(filepath, newarg))
81+
}
82+
83+
try {
84+
const data = await fslice(
85+
filepath,
86+
0,
87+
parsedOptions.c || parsedOptions.n || 10,
88+
parsedOptions.c ? 'bytes' : 'lines'
89+
)
90+
return showResponseAsMMR(filepath, data)
91+
} catch (err) {
92+
throw new Error(`head: ${err.message}`)
93+
}
94+
}
95+
96+
/**
97+
* Register command handlers
98+
*
99+
*/
100+
export default (registrar: Registrar) => {
101+
registrar.listen('/head', head, {
102+
requiresLocal: true
103+
})
104+
}

plugins/plugin-bash-like/fs/src/lib/open.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const strings = i18n('plugin-bash-like')
3737
const debug = Debug('plugins/bash-like/cmds/open')
3838

3939
/** Important for alignment to the Editor view component */
40-
function contentTypeOf(suffix: string): SupportedStringContent {
40+
export function contentTypeOf(suffix: string): SupportedStringContent {
4141
switch (suffix) {
4242
case 'sh':
4343
return 'shell'

plugins/plugin-bash-like/fs/src/plugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import cd from './lib/cd'
1818
import ls from './lib/ls'
1919
import edit from './lib/edit'
20+
import head from './lib/head'
2021
import open from './lib/open'
2122
import fwrite from './lib/fwrite'
2223
import mkTemp from './lib/mkTemp'
@@ -34,6 +35,7 @@ export default async (registrar: Registrar) => {
3435
cd(registrar),
3536
ls(registrar),
3637
edit(registrar),
38+
head(registrar),
3739
open(registrar),
3840
fwrite(registrar),
3941
mkTemp(registrar),

plugins/plugin-bash-like/fs/src/vfs/delegates.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,54 @@ export async function fstat(...parameters: Parameters<VFS['fstat']>): ReturnType
218218
return mount.fstat(parameters[0], parameters[1], parameters[2], parameters[3])
219219
}
220220

221+
/**
222+
* fslice delegate
223+
*
224+
*/
225+
export async function fslice(
226+
filepath: string,
227+
offsetAsProvided: number,
228+
length: number,
229+
unit: 'bytes' | 'lines'
230+
): ReturnType<VFS['fslice']> {
231+
const mount = findMount(filepath, undefined, true)
232+
233+
if (!mount) {
234+
throw new Error(`head: can not find ${filepath}`)
235+
}
236+
237+
if (unit === 'bytes') {
238+
return mount.fslice(filepath, offsetAsProvided, length)
239+
} else {
240+
let offset = offsetAsProvided
241+
let dataRead = ''
242+
while (true) {
243+
try {
244+
const data = await mount.fslice(filepath, offset, 1000000)
245+
if (data) {
246+
dataRead = dataRead.concat(data)
247+
const lines = dataRead.split('\n')
248+
249+
if (lines.length >= length) {
250+
dataRead = lines.slice(0, length).join('\n')
251+
break
252+
} else {
253+
offset = Buffer.from(dataRead).length
254+
}
255+
} else {
256+
console.error('bash-like fslice: no data read')
257+
break
258+
}
259+
} catch (err) {
260+
console.error(err)
261+
break
262+
}
263+
}
264+
265+
return dataRead
266+
}
267+
}
268+
221269
/**
222270
* mkdir delegate
223271
*

plugins/plugin-bash-like/fs/src/vfs/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ export interface VFS {
9797
enoentOk?: boolean
9898
): Promise<FStat>
9999

100+
/** Fetch content slice */
101+
fslice(filename: string, offset: number, length: number): Promise<string>
102+
100103
/** Create a directory/bucket */
101104
mkdir(
102105
opts: Pick<Arguments, 'command' | 'REPL' | 'argvNoOptions' | 'parsedOptions' | 'execOptions'>,

plugins/plugin-bash-like/fs/src/vfs/local.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { Arguments, encodeComponent } from '@kui-shell/core'
17+
import { open, read, stat } from 'fs'
18+
import { Arguments, encodeComponent, expandHomeDir, findFileWithViewer } from '@kui-shell/core'
1819

1920
import { VFS, mount } from '.'
2021
import { kuiglob, KuiGlobOptions } from '../lib/glob'
@@ -67,6 +68,39 @@ class LocalVFS implements VFS {
6768
})
6869
}
6970

71+
/** Fetch content slice */
72+
public async fslice(filepath: string, offset: number, _length: number): Promise<string> {
73+
const { resolved: fullpath } = findFileWithViewer(expandHomeDir(filepath))
74+
75+
return new Promise((resolve, reject) => {
76+
open(fullpath, 'r', (err, fd) => {
77+
if (err) {
78+
reject(err)
79+
} else {
80+
stat(fullpath, (err, stats) => {
81+
if (err) reject(err)
82+
try {
83+
if (offset >= stats.size) {
84+
reject(new Error('local fslice: reach file end'))
85+
} else {
86+
const length = _length <= stats.size ? _length : stats.size
87+
read(fd, Buffer.alloc(length), 0, length, offset, (_err, _, buff) => {
88+
if (_err) {
89+
reject(_err)
90+
} else {
91+
resolve(buff.toString())
92+
}
93+
})
94+
}
95+
} catch (err) {
96+
reject(err)
97+
}
98+
})
99+
}
100+
})
101+
})
102+
}
103+
70104
/** Create a directory/bucket */
71105
public async mkdir(opts: Pick<Arguments, 'command' | 'REPL' | 'parsedOptions' | 'execOptions'>): Promise<void> {
72106
await opts.REPL.qexec(
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
* Copyright 2017-19 IBM Corporation
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { dirname, join } from 'path'
18+
19+
import { Common, CLI, ReplExpect, Selectors, SidecarExpect, Util } from '@kui-shell/test'
20+
21+
const ROOT = dirname(require.resolve('@kui-shell/core/tests/package.json'))
22+
const rootRelative = (dir: string) => join(ROOT, dir)
23+
24+
describe(`bash-like head ${process.env.MOCHA_RUN_TARGET || ''}`, function(this: Common.ISuite) {
25+
before(Common.before(this))
26+
after(Common.after(this))
27+
28+
it('should head package.json and see 10 lines', () =>
29+
CLI.command(`head ${rootRelative('package.json')}`, this.app)
30+
.then(ReplExpect.ok)
31+
.then(SidecarExpect.open)
32+
.then(res =>
33+
this.app.client.waitUntil(async () => {
34+
const linesNumbers = await this.app.client.$$(
35+
Selectors.SIDECAR_CUSTOM_CONTENT_LINE_NUMBERS(res.count, res.splitIndex)
36+
)
37+
return linesNumbers.length === 10
38+
})
39+
)
40+
.catch(Common.oops(this)))
41+
42+
it('should head -n 5 package.json and see 5 lines', () =>
43+
CLI.command(`head -n 5 ${rootRelative('package.json')}`, this.app)
44+
.then(ReplExpect.ok)
45+
.then(SidecarExpect.open)
46+
.then(res =>
47+
this.app.client.waitUntil(async () => {
48+
const linesNumbers = await this.app.client.$$(
49+
Selectors.SIDECAR_CUSTOM_CONTENT_LINE_NUMBERS(res.count, res.splitIndex)
50+
)
51+
return linesNumbers.length === 5
52+
})
53+
)
54+
.catch(Common.oops(this)))
55+
56+
it('should head -c 1 package.json and see 1 bytes', () =>
57+
CLI.command(`head -c 1 ${rootRelative('package.json')}`, this.app)
58+
.then(ReplExpect.ok)
59+
.then(SidecarExpect.open)
60+
.then(res =>
61+
this.app.client.waitUntil(async () => {
62+
const value = await Util.getValueFromMonaco(res)
63+
const bufferSize = Buffer.from(value).length
64+
return bufferSize === 1
65+
})
66+
)
67+
.catch(Common.oops(this)))
68+
69+
it('should head /kui/welcome.json and see 10 lines', () =>
70+
CLI.command('head /kui/welcome.json', this.app)
71+
.then(ReplExpect.ok)
72+
.then(SidecarExpect.open)
73+
.then(res =>
74+
this.app.client.waitUntil(async () => {
75+
const linesNumbers = await this.app.client.$$(
76+
Selectors.SIDECAR_CUSTOM_CONTENT_LINE_NUMBERS(res.count, res.splitIndex)
77+
)
78+
return linesNumbers.length === 10
79+
})
80+
)
81+
.catch(Common.oops(this)))
82+
83+
it('should head -n 5 /kui/welcome.json and see 5 lines', () =>
84+
CLI.command('head -n 5 /kui/welcome.json', this.app)
85+
.then(ReplExpect.ok)
86+
.then(SidecarExpect.open)
87+
.then(res =>
88+
this.app.client.waitUntil(async () => {
89+
const linesNumbers = await this.app.client.$$(
90+
Selectors.SIDECAR_CUSTOM_CONTENT_LINE_NUMBERS(res.count, res.splitIndex)
91+
)
92+
return linesNumbers.length === 5
93+
})
94+
)
95+
.catch(Common.oops(this)))
96+
97+
it('should head -c 1 /kui/welcome.json and see 1 bytes', () =>
98+
CLI.command('head -c 1 /kui/welcome.json', this.app)
99+
.then(ReplExpect.ok)
100+
.then(SidecarExpect.open)
101+
.then(res =>
102+
this.app.client.waitUntil(async () => {
103+
const value = await Util.getValueFromMonaco(res)
104+
const bufferSize = Buffer.from(value).length
105+
return bufferSize === 1
106+
})
107+
)
108+
.catch(Common.oops(this)))
109+
})

plugins/plugin-core-support/src/notebooks/vfs/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,21 @@ export class NotebookVFS implements VFS {
225225
}
226226
}
227227

228+
/** Fetch content slice */
229+
public async fslice(filename: string, offset: number, length: number): Promise<string> {
230+
const entry = this.findExact(filename, true)
231+
if (entry.data) {
232+
const buffer = Buffer.from(entry.data)
233+
if (offset > buffer.length) {
234+
throw new Error(`notebook fslice: reach file end`)
235+
} else {
236+
return buffer.slice(offset, length + offset).toString()
237+
}
238+
} else {
239+
throw new Error(`fslice: data not found ${filename}`)
240+
}
241+
}
242+
228243
/** Create a directory/bucket */
229244
public async mkdir(opts: Pick<Arguments, 'argvNoOptions'>): Promise<void> {
230245
const mountPath = opts.argvNoOptions[opts.argvNoOptions.indexOf('mkdir') + 1]

0 commit comments

Comments
 (0)