file stuff

This commit is contained in:
Chris Wanstrath 2025-09-21 20:06:39 -07:00
parent cd3c1de04b
commit 320a456fde
8 changed files with 74 additions and 45 deletions

View File

@ -6,6 +6,7 @@ import type { CommandOutput } from "@/shared/types"
import { NOSE_WWW } from "@/config"
import { getState } from "@/state"
import { appPath } from "@/webapp"
import { isBinaryFile } from "@/utils"
import { highlight } from "../lib/highlight"
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>`
}
default:
if (await isBinaryContent(file))
if (await isBinaryFile(path))
throw "Cannot display binary file"
return {
@ -77,30 +78,3 @@ function convertIndent(str: string) {
.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()
}
}

View File

@ -1,4 +1,4 @@
// simple-ts-highlighter.ts — regex-only, self-hostable
import { escapeHTML } from "bun"
export type TokenType =
| "string" | "number" | "keyword" | "boolean" | "null" | "undefined"
@ -110,17 +110,17 @@ export function tokenize(src: string): Program {
function tokenToHTML(token: Token): string {
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 "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":
return `<span style="color: var(--green)">${token.value}</span>`
case "punctuation": {
// if (token.value === "(" || token.value === ")" || token.value === "{" || token.value === "}" || token.value === "[" || token.value === "]")
// return `<span style="color: var(--yellow)">${token.value}</span>`
// else
return escapeHtml(token.value)
return escapeHTML(token.value)
}
case "identifier": {
if (token.value[0]?.match(/[A-Z]/) || types.includes(token.value))
@ -130,15 +130,7 @@ function tokenToHTML(token: Token): string {
}
case "whitespace":
case "unknown":
return `${escapeHtml(token.value)}`
return `${escapeHTML(token.value)}`
}
}
export function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
}

View File

@ -3,6 +3,7 @@ import type { FC } from "hono/jsx"
export const Terminal: FC = async () => (
<>
<link rel="stylesheet" href="/css/terminal.css" />
<link rel="stylesheet" href="/css/editor.css" />
<div id="command-line">
<span id="command-prompt">&gt;</span>

4
src/css/editor.css Normal file
View File

@ -0,0 +1,4 @@
.editor {
width: 100%;
background-color: var(--white);
}

27
src/js/editor.ts Normal file
View 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 })
}

View File

@ -5,10 +5,10 @@ import { cmdInput } from "./dom.js"
export function initFocus() {
window.addEventListener("click", focusHandler)
focusTextbox()
focusInput()
}
export function focusTextbox() {
export function focusInput() {
cmdInput.focus()
}
@ -19,7 +19,7 @@ export function focusHandler(e: MouseEvent) {
// who knows where they clicked... just focus the textbox
if (!(target instanceof HTMLElement)) {
cmdInput.focus()
focusInput()
return
}
@ -32,7 +32,7 @@ export function focusHandler(e: MouseEvent) {
const selection = window.getSelection() || ""
if (selection.toString() === "")
cmdInput.focus()
focusInput()
e.preventDefault()
return true

View File

@ -1,5 +1,6 @@
import { initCompletion } from "./completion.js"
import { initCursor } from "./cursor.js"
import { initEditor } from "./editor.js"
import { initFocus } from "./focus.js"
import { initHistory } from "./history.js"
import { initInput } from "./input.js"
@ -10,6 +11,7 @@ import { startConnection } from "./websocket.js"
initCompletion()
initCursor()
initFocus()
initEditor()
initHistory()
initInput()
initResize()

View File

@ -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
export function tilde(path: string): string {
return path.replace(new RegExp(`/(Users|home)/${process.env.USER}`), "~")