file stuff
This commit is contained in:
parent
cd3c1de04b
commit
320a456fde
|
|
@ -6,6 +6,7 @@ import type { CommandOutput } from "@/shared/types"
|
||||||
import { NOSE_WWW } from "@/config"
|
import { NOSE_WWW } from "@/config"
|
||||||
import { getState } from "@/state"
|
import { getState } from "@/state"
|
||||||
import { appPath } from "@/webapp"
|
import { appPath } from "@/webapp"
|
||||||
|
import { isBinaryFile } from "@/utils"
|
||||||
import { highlight } from "../lib/highlight"
|
import { highlight } from "../lib/highlight"
|
||||||
|
|
||||||
export default async function (path: string) {
|
export default async function (path: string) {
|
||||||
|
|
@ -50,7 +51,7 @@ async function readFile(path: string): Promise<CommandOutput> {
|
||||||
html: `<div style='white-space: pre;'>${highlight(convertIndent(await file.text()))}</div>`
|
html: `<div style='white-space: pre;'>${highlight(convertIndent(await file.text()))}</div>`
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if (await isBinaryContent(file))
|
if (await isBinaryFile(path))
|
||||||
throw "Cannot display binary file"
|
throw "Cannot display binary file"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -77,30 +78,3 @@ function convertIndent(str: string) {
|
||||||
.join("\n")
|
.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async function isBinaryContent(file: Bun.FileBlob): Promise<boolean> {
|
|
||||||
// Create a stream to read just the beginning
|
|
||||||
const stream = file.stream()
|
|
||||||
const reader = stream.getReader()
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Read first chunk (typically 16KB, which is more than enough to detect binary)
|
|
||||||
const { value } = await reader.read()
|
|
||||||
if (!value) return false
|
|
||||||
|
|
||||||
// Check first 512 bytes or less
|
|
||||||
const bytes = new Uint8Array(value)
|
|
||||||
const checkLength = Math.min(bytes.length, 512)
|
|
||||||
for (let i = 0; i < checkLength; i++) {
|
|
||||||
const byte = bytes[i]!
|
|
||||||
if (byte === 0) return true // null byte
|
|
||||||
if (byte < 32 && ![9, 10, 13].includes(byte)) return true // control char
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock()
|
|
||||||
stream.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// simple-ts-highlighter.ts — regex-only, self-hostable
|
import { escapeHTML } from "bun"
|
||||||
|
|
||||||
export type TokenType =
|
export type TokenType =
|
||||||
| "string" | "number" | "keyword" | "boolean" | "null" | "undefined"
|
| "string" | "number" | "keyword" | "boolean" | "null" | "undefined"
|
||||||
|
|
@ -110,17 +110,17 @@ export function tokenize(src: string): Program {
|
||||||
|
|
||||||
function tokenToHTML(token: Token): string {
|
function tokenToHTML(token: Token): string {
|
||||||
switch (token.type) {
|
switch (token.type) {
|
||||||
case "string": return `<span style="color: var(--red)">${escapeHtml(token.value)}</span>`
|
case "string": return `<span style="color: var(--red)">${escapeHTML(token.value)}</span>`
|
||||||
case "number": return `<span style="color: var(--yellow)">${token.value}</span>`
|
case "number": return `<span style="color: var(--yellow)">${token.value}</span>`
|
||||||
case "keyword": return `<span style="color: var(--purple)">${token.value}</span>`
|
case "keyword": return `<span style="color: var(--purple)">${token.value}</span>`
|
||||||
case "comment": return `<span style="color: var(--magenta)">${escapeHtml(token.value)}</span>`
|
case "comment": return `<span style="color: var(--magenta)">${escapeHTML(token.value)}</span>`
|
||||||
case "null": case "undefined": case "boolean":
|
case "null": case "undefined": case "boolean":
|
||||||
return `<span style="color: var(--green)">${token.value}</span>`
|
return `<span style="color: var(--green)">${token.value}</span>`
|
||||||
case "punctuation": {
|
case "punctuation": {
|
||||||
// if (token.value === "(" || token.value === ")" || token.value === "{" || token.value === "}" || token.value === "[" || token.value === "]")
|
// if (token.value === "(" || token.value === ")" || token.value === "{" || token.value === "}" || token.value === "[" || token.value === "]")
|
||||||
// return `<span style="color: var(--yellow)">${token.value}</span>`
|
// return `<span style="color: var(--yellow)">${token.value}</span>`
|
||||||
// else
|
// else
|
||||||
return escapeHtml(token.value)
|
return escapeHTML(token.value)
|
||||||
}
|
}
|
||||||
case "identifier": {
|
case "identifier": {
|
||||||
if (token.value[0]?.match(/[A-Z]/) || types.includes(token.value))
|
if (token.value[0]?.match(/[A-Z]/) || types.includes(token.value))
|
||||||
|
|
@ -130,15 +130,7 @@ function tokenToHTML(token: Token): string {
|
||||||
}
|
}
|
||||||
case "whitespace":
|
case "whitespace":
|
||||||
case "unknown":
|
case "unknown":
|
||||||
return `${escapeHtml(token.value)}`
|
return `${escapeHTML(token.value)}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeHtml(str: string): string {
|
|
||||||
return str
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
}
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type { FC } from "hono/jsx"
|
||||||
export const Terminal: FC = async () => (
|
export const Terminal: FC = async () => (
|
||||||
<>
|
<>
|
||||||
<link rel="stylesheet" href="/css/terminal.css" />
|
<link rel="stylesheet" href="/css/terminal.css" />
|
||||||
|
<link rel="stylesheet" href="/css/editor.css" />
|
||||||
|
|
||||||
<div id="command-line">
|
<div id="command-line">
|
||||||
<span id="command-prompt">></span>
|
<span id="command-prompt">></span>
|
||||||
|
|
|
||||||
4
src/css/editor.css
Normal file
4
src/css/editor.css
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.editor {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--white);
|
||||||
|
}
|
||||||
27
src/js/editor.ts
Normal file
27
src/js/editor.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { scrollback } from "./dom.js"
|
||||||
|
|
||||||
|
export function initEditor() {
|
||||||
|
document.addEventListener("input", adjustHeight)
|
||||||
|
focusTextareaOnCreation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustHeight(e: Event) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target?.matches(".editor")) {
|
||||||
|
target.style.height = "auto"
|
||||||
|
target.style.height = target.scrollHeight + "px"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusTextareaOnCreation() {
|
||||||
|
const observer = new MutationObserver(mutations => {
|
||||||
|
for (const m of mutations)
|
||||||
|
for (const node of Array.from(m.addedNodes))
|
||||||
|
if (node instanceof HTMLElement && node.childNodes[0] instanceof HTMLElement && node.childNodes[0].matches("textarea")) {
|
||||||
|
node.childNodes[0].focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
observer.observe(scrollback, { childList: true })
|
||||||
|
}
|
||||||
|
|
@ -5,10 +5,10 @@ import { cmdInput } from "./dom.js"
|
||||||
|
|
||||||
export function initFocus() {
|
export function initFocus() {
|
||||||
window.addEventListener("click", focusHandler)
|
window.addEventListener("click", focusHandler)
|
||||||
focusTextbox()
|
focusInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function focusTextbox() {
|
export function focusInput() {
|
||||||
cmdInput.focus()
|
cmdInput.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ export function focusHandler(e: MouseEvent) {
|
||||||
|
|
||||||
// who knows where they clicked... just focus the textbox
|
// who knows where they clicked... just focus the textbox
|
||||||
if (!(target instanceof HTMLElement)) {
|
if (!(target instanceof HTMLElement)) {
|
||||||
cmdInput.focus()
|
focusInput()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function focusHandler(e: MouseEvent) {
|
||||||
|
|
||||||
const selection = window.getSelection() || ""
|
const selection = window.getSelection() || ""
|
||||||
if (selection.toString() === "")
|
if (selection.toString() === "")
|
||||||
cmdInput.focus()
|
focusInput()
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { initCompletion } from "./completion.js"
|
import { initCompletion } from "./completion.js"
|
||||||
import { initCursor } from "./cursor.js"
|
import { initCursor } from "./cursor.js"
|
||||||
|
import { initEditor } from "./editor.js"
|
||||||
import { initFocus } from "./focus.js"
|
import { initFocus } from "./focus.js"
|
||||||
import { initHistory } from "./history.js"
|
import { initHistory } from "./history.js"
|
||||||
import { initInput } from "./input.js"
|
import { initInput } from "./input.js"
|
||||||
|
|
@ -10,6 +11,7 @@ import { startConnection } from "./websocket.js"
|
||||||
initCompletion()
|
initCompletion()
|
||||||
initCursor()
|
initCursor()
|
||||||
initFocus()
|
initFocus()
|
||||||
|
initEditor()
|
||||||
initHistory()
|
initHistory()
|
||||||
initInput()
|
initInput()
|
||||||
initResize()
|
initResize()
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,35 @@ export function isDir(path: string): boolean {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// is the given file binary?
|
||||||
|
export async function isBinaryFile(path: string): Promise<boolean> {
|
||||||
|
// Create a stream to read just the beginning
|
||||||
|
const file = Bun.file(path)
|
||||||
|
const stream = file.stream()
|
||||||
|
const reader = stream.getReader()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read first chunk (typically 16KB, which is more than enough to detect binary)
|
||||||
|
const { value } = await reader.read()
|
||||||
|
if (!value) return false
|
||||||
|
|
||||||
|
// Check first 512 bytes or less
|
||||||
|
const bytes = new Uint8Array(value)
|
||||||
|
const checkLength = Math.min(bytes.length, 512)
|
||||||
|
for (let i = 0; i < checkLength; i++) {
|
||||||
|
const byte = bytes[i]!
|
||||||
|
if (byte === 0) return true // null byte
|
||||||
|
if (byte < 32 && ![9, 10, 13].includes(byte)) return true // control char
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock()
|
||||||
|
stream.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Convert /Users/$USER or /home/$USER to ~ for simplicity
|
// Convert /Users/$USER or /home/$USER to ~ for simplicity
|
||||||
export function tilde(path: string): string {
|
export function tilde(path: string): string {
|
||||||
return path.replace(new RegExp(`/(Users|home)/${process.env.USER}`), "~")
|
return path.replace(new RegExp(`/(Users|home)/${process.env.USER}`), "~")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user