Merge branch 'main' of github.com:wavetermdev/waveterm into red/new-tabs

This commit is contained in:
Red Adaya 2024-03-19 14:23:04 +08:00
commit 5ae6a32222
39 changed files with 1099 additions and 330 deletions

View File

@ -7,16 +7,6 @@
:root {
--fa-style-family: "Font Awesome 6 Sharp";
/* these variables are overridden by user settings */
/*
--termfontfamily: "Hack";
--termfontsize: 13px;
--termlineheight: 15px;
--termpad: 7px;
--termfontsize-sm: 11px;
--termlineheight-sm: 13px;
*/
/* base fonts */
--base-font: normal 15px / normal "Lato", sans-serif;
@ -52,12 +42,10 @@
--app-bg-color: black;
--app-accent-color: rgb(88, 193, 66);
--app-accent-bg-color: rgba(88, 193, 66, 0.25);
--app-error-color: rgb(229, 77, 46);
--app-warning-color: rgb(224, 185, 86);
--app-success-color: rgb(78, 154, 6);
--app-text-color: rgb(211, 215, 207);
--app-text-primary-color: rgb(255, 255, 255);
--app-text-secondary-color: rgb(195, 200, 194);
--app-text-disabled-color: rgb(173, 173, 173);
--app-border-color: rgb(51, 51, 51);
--app-maincontent-bg-color: #333;
--app-panel-bg-color: rgba(21, 23, 21, 1);
@ -65,17 +53,15 @@
--app-icon-color: rgb(139, 145, 138);
--app-icon-hover-color: #fff;
--app-selected-mask-color: rgba(255, 255, 255, 0.06);
/* icon colors */
--app-error-color: rgb(229, 77, 46);
--app-warning-color: rgb(224, 185, 86);
--app-success-color: rgb(78, 154, 6);
--app-idle-color: var(--app-text-secondary-color);
/* just for macos */
--app-border-radius-darwin: 10px;
/* global generic colors */
--app-black: rgb(0, 0, 0);
/* scrollbar colors */
/* --scrollbar-background-color: rgba(21, 23, 21, 1); */
--scrollbar-background-color: var(--app-bg-color);
--scrollbar-thumb-color: rgba(255, 255, 255, 0.3);
--scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5);
@ -116,28 +102,9 @@
--markdown-bg-color: rgb(35, 35, 35);
--markdown-outline-color: var(--form-element-primary-color);
/* status(remote) colors */
/* todo: all status colors must be unified */
--status-connected-color: var(--app-success-color);
--status-connecting-color: var(--app-warning-color);
--status-error-color: var(--app-error-color);
--status-disconnected-color: var(--app-text-secondary-color);
/* status indicator colors */
/* todo: all status colors must be unified */
--status-indicator-color: var(--app-text-color);
--status-indicator-error: var(--status-error-color);
--status-indicator-success: var(--status-connected-color);
/* status(version) colors */
/* todo: all status colors must be unified */
--status-outdated-color: var(--status-connecting-color);
--status-updated-color: var(--status-connected-color);
/* term status colors */
/* todo: all status colors must be unified */
--term-error-color: var(--status-error-color);
--term-warning-color: var(--status-connecting-color);
--term-error-color: var(--app-error-color);
--term-warning-color: var(--app-warning-color);
/* hotkey colors */
--hotkey-text-color: var(--app-text-secondary-color);
@ -178,17 +145,6 @@
--line-actions-active-color: rgba(255, 255, 255, 1);
--line-actions-bg-color: rgba(255, 255, 255, 0.15);
/* view colors */
/* todo: bookmarks is a view, colors must be unified with --view* colors */
--bookmarks-text-color: rgb(211, 215, 207);
--bookmarks-textarea-bg-color: rgb(0, 0, 0);
--bookmarks-disabled-text-color: rgb(173, 173, 173);
--bookmarks-control-hover-color: rgb(255, 255, 255);
/* view colors */
--view-error-color: var(--app-error-color);
--view-text-color: var(--app-text-color);
/* table colors */
--table-border-color: rgba(241, 246, 243, 0.15);
--table-thead-border-top-color: rgba(250, 250, 250, 0.1);
@ -199,9 +155,6 @@
--table-tr-selected-bg-color: #222;
--table-tr-selected-hover-bg-color: #333;
/* session colors */
--session-bg-color: rgba(13, 13, 13, 0.85);
/* cmdinput colors */
--cmdinput-textarea-bg-color: #171717;
--cmdinput-text-error-color: var(--term-red);
@ -223,4 +176,12 @@
--modal-header-bottom-border-color: rgba(241, 246, 243, 0.15);
--logo-button-hover-bg-color: var(--app-accent-bg-color);
--xterm-viewport-border-color: rgba(241, 246, 243, 0.15);
--datepicker-cell-hover-bg-color: rgba(255, 255, 255, 0.06);
--datepicker-cell-other-text-color: rgba(255, 255, 255, 0.3);
--datepicker-header-fade-color: rgba(255, 255, 255, 0.4);
--datepicker-year-header-bg-color: rgba(255, 255, 255, 0.2); /* Light grey background */
--datepicker-year-header-border-color: rgba(241, 246, 243, 0.15); /* Light grey border */
}

View File

@ -31,7 +31,7 @@
--button-secondary-bg-color: rgba(200, 200, 200, 0.3); /* Semi-transparent light gray */
/* view colors */
--view-text-color: var(--app-text-color);
--app-text-color: var(--app-text-color);
/* table colors */
--table-thead-border-top-color: rgba(0, 0, 0, 0.2);
@ -71,6 +71,16 @@
--line-actions-inactive-color: rgba(0, 0, 0, 0.3);
--line-actions-active-color: rgba(0, 0, 0, 1);
--logo-button-hover-bg-color: #f0f0f0;
--xterm-viewport-border-color: rgba(0, 0, 0, 0.3);
--datepicker-cell-hover-bg-color: #f0f0f0;
--datepicker-cell-other-text-color: rgba(0, 0, 0, 0.3);
--datepicker-header-fade-color: rgba(0, 0, 0, 0.4);
--datepicker-year-header-bg-color: #f5f5f5;
--datepicker-year-header-border-color: #dcdcdc;
/* toggle colors */
--toggle-thumb-color: var(--app-bg-color);
}

View File

@ -735,28 +735,28 @@ a.a-block {
.status-icon.status-connected {
path,
circle {
fill: var(--status-connected-color);
fill: var(--app-success-color);
}
}
.status-icon.status-connecting {
path,
circle {
fill: var(--status-connecting-color);
fill: var(--app-warning-color);
}
}
.status-icon.status-disconnected {
path,
circle {
fill: var(--status-disconnected-color);
fill: var(--app-idle-color);
}
}
.status-icon.status-error {
path,
circle {
fill: var(--status-error-color);
fill: var(--app-text-disabled-color);
}
}

View File

@ -6,11 +6,11 @@
}
.bookmarks-list {
color: var(--bookmarks-text-color);
color: var(--app-text-color);
margin: 4px 10px 5px 5px;
.no-bookmarks {
color: var(--bookmarks-text-color);
color: var(--app-text-color);
padding: 30px 10px 35px 10px;
border-bottom: 1px solid white;
}
@ -46,21 +46,21 @@
}
label {
color: var(--bookmarks-text-color);
color: var(--app-text-color);
margin-bottom: 4px;
}
textarea {
width: 80%;
min-width: 50%;
color: var(--bookmarks-text-color);
background-color: var(--bookmarks-textarea-bg-color);
color: var(--app-text-color);
background-color: var(--form-element-bg-color);
}
.bookmark-id-div {
display: none;
position: absolute;
color: var(--bookmarks-disabled-text-color);
color: var(--app-text-disabled-color);
right: 5px;
bottom: 2px;
font-size: 0.8em;
@ -75,7 +75,7 @@
flex-direction: row;
visibility: hidden;
color: var(--bookmarks-text-color);
color: var(--app-text-color);
.bookmark-control:first-child {
margin-left: 0;
@ -85,10 +85,6 @@
margin-left: 10px;
cursor: pointer;
padding: 2px;
&:hover {
color: var(--bookmarks-control-hover-color);
}
}
}

View File

@ -1,8 +1,6 @@
.wave-button {
background: none;
color: inherit;
border: none;
font: inherit;
cursor: pointer;
outline: inherit;
display: flex;
@ -12,64 +10,64 @@
border-radius: 6px;
height: auto;
line-height: 1.5;
display: block;
white-space: nowrap;
color: var(--form-element-text-color);
background: var(--form-element-primary-color);
i {
fill: var(--form-element-text-color);
}
&.primary {
color: var(--form-element-text-color);
background: var(--form-element-primary-color);
i {
fill: var(--form-element-text-color);
}
}
&.primary.outlined {
background: none;
border: 1px solid var(--form-element-primary-color);
i {
fill: var(--form-element-primary-color);
}
}
&.solid {
color: var(--form-element-text-color);
background: var(--form-element-primary-color);
i {
fill: var(--form-element-text-color);
}
}
&.outlined {
background: none;
border: 1px solid var(--form-element-primary-color);
}
&.ghost {
background: none;
}
&:hover {
color: var(--form-element-text-color);
&.primary.ghost {
background: none;
i {
fill: var(--form-element-primary-color);
}
}
&.secondary {
color: var(--form-element-text-color);
background: none;
color: var(--form-element-text-color);
background: var(--form-element-secondary-color);
box-shadow: none;
&.solid {
color: var(--form-element-text-color);
background: var(--form-element-secondary-color);
box-shadow: none;
i {
fill: var(--form-element-text-color);
}
}
&.outlined {
background: none;
border: 1px solid var(--form-element-secondary-color);
}
&.secondary.outlined {
background: none;
border: 1px solid var(--form-element-text-color);
}
&.ghost {
background: none;
padding: 6px 10px;
&.secondary.ghost {
background: none;
}
i {
fill: var(--form-element-primary-color);
}
}
&.secondary.danger {
color: var(--app-text-disabled-color);
}
&.small {
padding: 4px 8px;
font-size: 12px;
border-radius: 3.6px;
}
&.term-inline {

View File

@ -0,0 +1,183 @@
.day-picker-modal {
background-color: var(--form-element-bg-color);
z-index: 1000;
width: 250px;
height: 293px;
font-size: 13px;
border: 1px solid var(--form-element-border-color);
border-radius: 4px;
color: var(--form-element-text-color);
}
.day-picker-input {
cursor: default;
border: 1px solid var(--form-element-border-color) !important;
height: 34px;
user-select: none;
display: flex;
border-radius: 4px;
justify-content: space-between;
align-items: center;
padding: 0 15px !important;
display: flex;
align-items: center;
justify-content: center;
input {
outline: none !important;
border: none !important;
height: 100%;
padding: 0 !important;
cursor: default !important;
}
.fa-calendar {
margin-left: 10px;
cursor: pointer;
}
}
.day-picker-header {
color: var(--form-element-text-color);
padding: 10px 10px 0;
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
position: sticky;
div {
cursor: default;
font-weight: bold;
user-select: none;
&.fade {
color: var(--datepicker-header-fade-color);
}
}
.arrows {
display: flex;
.wave-button {
padding: 5px !important;
font-size: 18px !important;
cursor: default !important;
}
}
}
.day-header {
display: flex;
color: var(--form-element-text-color);
padding: 0 10px;
.day-header-cell {
flex: 1;
padding: 10px;
color: var(--form-element-text-color);
text-align: center;
font-weight: bold;
user-select: none;
}
}
.day-picker {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
padding: 0 10px 10px;
.day {
padding: 10px 4px;
color: var(--form-element-text-color);
text-align: center;
cursor: default;
border-radius: 3px;
&:hover {
background-color: var(--datepicker-cell-hover-bg-color);
}
&.selected {
background-color: var(--form-element-primary-color);
font-weight: bold;
}
}
.other-month {
color: var(--datepicker-cell-other-text-color);
}
}
.year-month-accordion-wrapper {
overflow-y: auto;
height: 243px;
margin-top: 8px;
padding: 0 10px 10px;
.year-month-accordion {
color: var(--form-element-text-color);
}
.year-header {
padding: 5px 10px;
background-color: var(--datepicker-year-header-bg-color);
border-bottom: 1px solid var(--datepicker-year-header-border-color);
margin-bottom: 5px;
font-weight: bold;
text-align: center;
cursor: default;
color: var(--form-element-text-color);
user-select: none;
&:last-child {
margin-bottom: 0;
}
}
.month-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
padding: 10px;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
cursor: default;
}
.month {
padding: 5px;
text-align: center;
border-radius: 3px;
color: var(--form-element-text-color);
&:hover {
background-color: var(--datepicker-cell-hover-bg-color);
}
&.selected {
background-color: var(--form-element-primary-color);
}
}
.expanded {
max-height: 500px;
}
}
.dropdown-arrow {
display: inline-block;
margin-left: 5px;
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid var(--form-element-text-color);
vertical-align: middle;
user-select: none;
&.fade {
border-top: 5px solid var(--datepicker-header-fade-color);
}
}

View File

@ -0,0 +1,446 @@
import React, { useState, useEffect, useRef, createRef } from "react";
import ReactDOM from "react-dom";
import dayjs from "dayjs";
import cs from "classnames";
import { Button } from "@/elements";
import { If } from "tsx-control-statements/components";
import "./datepicker.less";
interface YearRefs {
[key: number]: React.RefObject<HTMLDivElement>;
}
type DatePickerProps = {
selectedDate: Date;
onSelectDate: (date: Date) => void;
format?: string;
};
const DatePicker: React.FC<DatePickerProps> = ({ selectedDate, format = "MM/DD/YYYY", onSelectDate }) => {
const [isOpen, setIsOpen] = useState(false);
const [selDate, setSelDate] = useState(dayjs(selectedDate)); // Initialize with dayjs object
const [showYearAccordion, setShowYearAccordion] = useState(false);
const [expandedYear, setExpandedYear] = useState<number | null>(selDate.year());
const yearRefs = useRef<YearRefs>({});
const wrapperRef = useRef<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const calendarIconRef = useRef(null);
const inputRefs = useRef({ YYYY: null, MM: null, DD: null });
// Extract delimiter using regex
const delimiter = format.replace(/[0-9YMD]/g, "")[0] || "/";
// Split format and create state for each part
const formatParts = format.split(delimiter);
const [dateParts, setDateParts] = useState({
YYYY: selDate.format("YYYY"),
MM: selDate.format("MM"),
DD: selDate.format("DD"),
});
useEffect(() => {
inputRefs.current = {
YYYY: createRef(),
MM: createRef(),
DD: createRef(),
};
}, []);
useEffect(() => {
if (showYearAccordion && expandedYear && yearRefs.current[expandedYear]) {
yearRefs.current[expandedYear].current?.scrollIntoView({
block: "nearest",
});
}
}, [showYearAccordion, expandedYear]);
useEffect(() => {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
useEffect(() => {
setDateParts({
YYYY: selDate.format("YYYY"),
MM: selDate.format("MM"),
DD: selDate.format("DD"),
});
}, [selDate]);
const handleClickOutside = (event: MouseEvent) => {
// Check if the click is on the calendar icon
if (calendarIconRef.current && calendarIconRef.current.contains(event.target as Node)) {
// Click is on the calendar icon, do nothing
return;
}
// Check if the click is outside the modal
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
setIsOpen(false); // Close the modal
}
};
const handleDayClick = (date: Date) => {
const newDate = dayjs(date);
setSelDate(newDate); // Update selDate with the new dayjs object
onSelectDate && onSelectDate(date); // Call parent's onSelectDate
setIsOpen(false); // Close the picker
};
const changeMonth = (delta: number) => {
const newDate = selDate.add(delta, "month");
setSelDate(newDate);
onSelectDate && onSelectDate(newDate.toDate());
};
const renderHeader = () => {
return (
<div className="day-picker-header">
<div
className={cs({ fade: showYearAccordion })}
onClick={() => {
if (!showYearAccordion) {
setExpandedYear(selDate.year()); // Set expandedYear when opening accordion
}
setShowYearAccordion(!showYearAccordion);
}}
>
{selDate.format("MMMM YYYY")}
<span className={cs("dropdown-arrow", { fade: showYearAccordion })}></span>
</div>
<If condition={!showYearAccordion}>
<div className="arrows">
<Button className="secondary ghost" onClick={() => changeMonth(-1)}>
&uarr;
</Button>
<Button className="secondary ghost" onClick={() => changeMonth(1)}>
&darr;
</Button>
</div>
</If>
</div>
);
};
const renderDayHeaders = () => {
const daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]; // First letter of each day
return (
<div className="day-header">
{daysOfWeek.map((day, i) => (
<div key={`${day}-${i}`} className="day-header-cell">
{day}
</div>
))}
</div>
);
};
const renderDays = () => {
const days = [];
const startDay = selDate.startOf("month");
const endDay = selDate.endOf("month");
const startDate = startDay.day(); // 0 for Sunday, 1 for Monday, ..., 6 for Saturday
// Previous month's filler days
const previousMonth = startDay.subtract(1, "month");
const daysInPreviousMonth = previousMonth.daysInMonth();
for (let i = daysInPreviousMonth - startDate + 1; i <= daysInPreviousMonth; i++) {
const dayDate = previousMonth.date(i);
days.push(
<div
key={`prev-month-day-${i}`}
className="day other-month"
onClick={() => handleDayClick(dayDate.toDate())}
>
{i}
</div>
);
}
// Current month's days
for (
let dayCount = 1;
startDay.add(dayCount - 1, "day").isBefore(endDay) ||
startDay.add(dayCount - 1, "day").isSame(endDay, "day");
dayCount++
) {
const currentDate = startDay.add(dayCount - 1, "day");
days.push(
<div
key={dayCount}
className={`day ${selDate.isSame(currentDate, "day") ? "selected" : ""}`}
onClick={() => handleDayClick(currentDate.toDate())}
>
{dayCount}
</div>
);
}
// Next month's filler days
while (days.length < 42) {
const fillerDayCount = days.length - daysInPreviousMonth - endDay.date();
const dayDate = endDay.add(fillerDayCount + 1, "day");
days.push(
<div
key={`next-month-day-${dayDate.format("YYYY-MM-DD")}`}
className="day other-month"
onClick={() => handleDayClick(dayDate.toDate())}
>
{dayDate.date()}
</div>
);
}
return days;
};
const calculatePosition = (): React.CSSProperties => {
if (wrapperRef.current) {
const rect = wrapperRef.current.getBoundingClientRect();
return {
position: "absolute",
top: `${rect.bottom + window.scrollY + 2}px`,
left: `${rect.left + window.scrollX}px`,
};
}
return {};
};
const populateYears = () => {
const currentYear = dayjs().year();
const startYear = currentYear - 10;
const endYear = currentYear + 10;
const yearsRange = [];
for (let year = startYear; year <= endYear; year++) {
yearsRange.push(year);
yearRefs.current[year] = React.createRef();
}
return yearsRange;
};
const handleMonthYearSelect = (month: number, year: number) => {
const newDate = dayjs(new Date(year, month - 1));
setSelDate(newDate);
setShowYearAccordion(false); // Close accordion
onSelectDate && onSelectDate(newDate.toDate());
};
const renderYearMonthAccordion = () => {
const years = populateYears();
const currentYear = selDate.year();
return (
<div className="year-month-accordion-wrapper">
<div className="year-month-accordion">
{years.map((year) => (
<div key={year} ref={yearRefs.current[year]}>
<div
className="year-header"
data-year={year}
onClick={() => setExpandedYear(year === expandedYear ? null : year)}
>
{year}
</div>
<If condition={expandedYear === year}>
<div
className={cs("month-container", {
expanded: expandedYear === year,
})}
>
{Array.from({ length: 12 }, (_, i) => i + 1).map((month) => (
<div
key={month}
className={cs("month", {
selected: year === currentYear && month === selDate.month() + 1,
})}
onClick={() => handleMonthYearSelect(month, year)}
>
{dayjs(new Date(year, month - 1)).format("MMM")}
</div>
))}
</div>
</If>
</div>
))}
</div>
</div>
);
};
const toggleModal = () => {
setIsOpen((prevIsOpen) => !prevIsOpen);
setShowYearAccordion(false);
};
const dayPickerModal = isOpen
? ReactDOM.createPortal(
<div ref={modalRef} className="day-picker-modal" style={calculatePosition()}>
{renderHeader()}
{showYearAccordion && renderYearMonthAccordion()}
<If condition={!showYearAccordion}>
<>
{renderDayHeaders()}
<div className="day-picker">{renderDays()}</div>
</>
</If>
</div>,
document.getElementById("app")!
)
: null;
const handleDatePartChange = (part, value) => {
const newDateParts = { ...dateParts, [part]: value };
setDateParts(newDateParts);
// Construct a new date from the updated parts
const newDate = dayjs(`${newDateParts.YYYY}-${newDateParts.MM}-${newDateParts.DD}`);
if (newDate.isValid()) {
onSelectDate(newDate.toDate()); // Call onSelectDate with the new date
}
};
const handleArrowNavigation = (event, currentPart) => {
const currentIndex = formatParts.indexOf(currentPart);
let targetInput;
if (event.key === "ArrowLeft" && currentIndex > 0) {
targetInput = inputRefs.current[formatParts[currentIndex - 1]].current;
} else if (event.key === "ArrowRight" && currentIndex < formatParts.length - 1) {
targetInput = inputRefs.current[formatParts[currentIndex + 1]].current;
}
if (targetInput) {
targetInput.focus();
}
};
const handleKeyDown = (event, currentPart) => {
const key = event.key;
if (key === "ArrowLeft" || key === "ArrowRight") {
// Handle arrow navigation without selecting text
handleArrowNavigation(event, currentPart);
return;
}
if (key === " ") {
// Handle spacebar press to toggle the modal
toggleModal();
return;
}
if (key.match(/[0-9]/)) {
// Handle numeric keys
event.preventDefault();
const maxLength = currentPart === "YYYY" ? 4 : 2;
const newValue = event.target.value.length < maxLength ? event.target.value + key : key;
let selectionTimeoutId = null;
handleDatePartChange(currentPart, newValue);
// Clear any existing timeout
if (selectionTimeoutId !== null) {
clearTimeout(selectionTimeoutId);
}
// Re-focus and select the input after state update
selectionTimeoutId = setTimeout(() => {
event.target.focus();
event.target.select();
}, 0);
}
};
const handleFocus = (event) => {
event.target.select();
};
// Prevent use from selecting text in the input
const handleMouseDown = (event) => {
event.preventDefault();
handleFocus(event);
};
const handleIconKeyDown = (event) => {
if (event.key === "Enter") {
toggleModal();
}
};
const setInputWidth = (inputRef, value) => {
const span = document.createElement("span");
document.body.appendChild(span);
span.style.font = "inherit";
span.style.visibility = "hidden";
span.style.position = "absolute";
span.textContent = value;
const textWidth = span.offsetWidth;
document.body.removeChild(span);
if (inputRef.current) {
inputRef.current.style.width = `${textWidth}px`;
}
};
useEffect(() => {
// This timeout ensures that the effect runs after the DOM updates
const timeoutId = setTimeout(() => {
formatParts.forEach((part) => {
const inputRef = inputRefs.current[part];
if (inputRef && inputRef.current) {
setInputWidth(inputRef, dateParts[part]);
}
});
}, 0);
return () => clearTimeout(timeoutId); // Cleanup timeout on unmount
}, []);
const renderDatePickerInput = () => {
return (
<div className="day-picker-input">
{formatParts.map((part, index) => {
const inputRef = inputRefs.current[part];
return (
<React.Fragment key={part}>
{index > 0 && <span>{delimiter}</span>}
<input
readOnly
ref={inputRef}
type="text"
value={dateParts[part]}
onChange={(e) => handleDatePartChange(part, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, part)}
onMouseDown={handleMouseDown}
onFocus={handleFocus}
maxLength={part === "YYYY" ? 4 : 2}
className="date-input"
placeholder={part}
/>
</React.Fragment>
);
})}
<i
ref={calendarIconRef}
className="fa-sharp fa-regular fa-calendar"
onClick={toggleModal}
onKeyDown={handleIconKeyDown}
tabIndex={0} // Makes the icon focusable
role="button" // Semantic role for accessibility
aria-label="Toggle date picker" // Accessible label for screen readers
/>
</div>
);
};
return (
<div ref={wrapperRef}>
{renderDatePickerInput()}
{dayPickerModal}
</div>
);
};
export { DatePicker };

View File

@ -17,3 +17,4 @@ export { TextField } from "./textfield";
export { Toggle } from "./toggle";
export { Tooltip } from "./tooltip";
export { TabIcon } from "./tabicon";
export { DatePicker } from "./datepicker";

View File

@ -11,18 +11,18 @@
}
.dot.green {
background-color: var(--status-connected-color);
background-color: var(--app-success-color);
}
.dot.red {
background-color: var(--status-error-color);
background-color: var(--app-text-disabled-color);
}
.dot.gray {
background-color: var(--status-disconnected-color);
background-color: var(--app-idle-color);
}
.dot.yellow {
background-color: var(--status-connecting-color);
background-color: var(--app-warning-color);
}
}

View File

@ -73,18 +73,18 @@ The following accounts for a debounce in the status indicator. We will only disp
visibility: hidden;
}
.spin #spinner {
stroke: var(--status-indicator-color);
stroke: var(--app-text-color);
}
&.error #indicator {
visibility: visible;
fill: var(--status-indicator-error);
fill: var(--app-text-disabled-color);
}
&.success #indicator {
visibility: visible;
fill: var(--status-indicator-success);
fill: var(--app-success-color);
}
&.output #indicator {
visibility: visible;
fill: var(--status-indicator-color);
fill: var(--app-text-color);
}
}

View File

@ -70,7 +70,7 @@
margin-bottom: 5px;
i {
color: var(--status-updated-color);
color: var(--app-success-color);
}
}
}
@ -78,7 +78,7 @@
.status.outdated {
div {
i {
color: var(--status-outdated-color);
color: var(--app-warning-color);
}
}

View File

@ -14,7 +14,7 @@ import "./alert.less";
class AlertModal extends React.Component<{}, {}> {
@boundMethod
closeModal(): void {
GlobalModel.cancelAlert();
GlobalModel.modalsModel.popModal(() => GlobalModel.cancelAlert());
}
@boundMethod

View File

@ -10,7 +10,7 @@ import cn from "classnames";
import { GlobalModel, GlobalCommandRunner, Screen } from "@/models";
import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Dropdown, Tooltip } from "@/elements";
import * as util from "@/util/util";
import { TabIcon } from "@/common/elements/tabicon";
import { TabIcon, Button } from "@/elements";
import { ReactComponent as GlobeIcon } from "@/assets/icons/globe.svg";
import { ReactComponent as StatusCircleIcon } from "@/assets/icons/statuscircle.svg";
import * as appconst from "@/app/appconst";
@ -334,12 +334,9 @@ class ScreenSettingsModal extends React.Component<{}, {}> {
</Tooltip>
</div>
<div className="settings-input">
<div
onClick={this.handleDeleteScreen}
className="button is-prompt-danger is-outlined is-small"
>
<Button onClick={this.handleDeleteScreen} className="secondary small danger">
Delete Tab
</div>
</Button>
</div>
</div>
<SettingsError errorMessage={this.errorMessage} />

View File

@ -6,6 +6,7 @@ import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { GlobalModel, GlobalCommandRunner, Session } from "@/models";
import { Button } from "@/elements";
import { Toggle, InlineSettingsTextEdit, SettingsError, Modal, Tooltip } from "@/elements";
import * as util from "@/util/util";
@ -133,12 +134,9 @@ class SessionSettingsModal extends React.Component<{}, {}> {
</Tooltip>
</div>
<div className="settings-input">
<div
onClick={this.handleDeleteSession}
className="button is-prompt-danger is-outlined is-small"
>
<Button onClick={this.handleDeleteSession} className="secondary small danger">
Delete Workspace
</div>
</Button>
</div>
</div>
<SettingsError errorMessage={this.errorMessage} />

View File

@ -192,7 +192,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
</Button>
);
let archiveButton = (
<Button className="secondary" onClick={() => this.clickArchive()}>
<Button className="secondary danger" onClick={() => this.clickArchive()}>
Delete
</Button>
);
@ -207,7 +207,7 @@ class ViewRemoteConnDetailModal extends React.Component<{}, {}> {
}
if (remote.sshconfigsrc == "sshconfig-import") {
archiveButton = (
<Button className="secondary" onClick={() => this.clickArchive()}>
<Button className="secondary danger" onClick={() => this.clickArchive()}>
Delete
<Tooltip
message={

View File

@ -33,13 +33,13 @@
th {
height: 32px;
padding: 5px 15px 5px 10px;
color: var(--view-text-color);
color: var(--app-text-color);
}
}
tr.connections-item {
border-bottom: 1px solid var(--table-tr-border-bottom-color);
color: var(--view-text-color);
color: var(--app-text-color);
cursor: pointer;
&:hover {

View File

@ -14,7 +14,7 @@ import localizedFormat from "dayjs/plugin/localizedFormat";
import customParseFormat from "dayjs/plugin/customParseFormat";
import { Line } from "@/app/line/linecomps";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil";
import { TextField, Dropdown, Button } from "@/elements";
import { TextField, Dropdown, Button, DatePicker } from "@/elements";
import { ReactComponent as AngleDownIcon } from "@/assets/icons/history/angle-down.svg";
import { ReactComponent as ChevronLeftIcon } from "@/assets/icons/history/chevron-left.svg";
@ -286,15 +286,16 @@ class HistoryView extends React.Component<{}, {}> {
}
@boundMethod
handleFromTsChange(e: any): void {
handleFromTsChange(date: Date): void {
let hvm = GlobalModel.historyViewModel;
let newDate = e.target.value;
let newDate = dayjs(date).format("YYYY-MM-DD");
let today = dayjs().format("YYYY-MM-DD");
if (newDate == "" || newDate == today) {
hvm.setFromDate(null);
return;
}
hvm.setFromDate(e.target.value);
console.log;
hvm.setFromDate(newDate);
}
@boundMethod
@ -472,13 +473,7 @@ class HistoryView extends React.Component<{}, {}> {
/>
<div className="fromts">
<div className="fromts-text">From:&nbsp;</div>
<div className="hoverEffect">
<input
type="date"
onChange={this.handleFromTsChange}
value={this.searchFromTsInputValue()}
/>
</div>
<DatePicker selectedDate={new Date()} onSelectDate={this.handleFromTsChange} />
</div>
<div
className="filter-cmds search-checkbox hoverEffect"

View File

@ -1,6 +1,4 @@
.plugins-view {
background-color: var(--session-bg-color);
.header {
margin: 1.5em 1.5em 0.5em;

View File

@ -138,7 +138,7 @@
color: var(--app-warning-color);
align-items: center;
.button {
.wave-button {
margin-left: 10px;
}
}

View File

@ -10,7 +10,7 @@ import cn from "classnames";
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import { GlobalModel, GlobalCommandRunner, Screen } from "@/models";
import { renderCmdText } from "@/elements";
import { Button } from "@/elements";
import { TextAreaInput } from "./textareainput";
import { InfoMsg } from "./infomsg";
import { HistoryInfo } from "./historyinfo";
@ -188,12 +188,12 @@ class CmdInput extends React.Component<{}, {}> {
<span className="remote-name">[{GlobalModel.resolveRemoteIdToFullRef(remote.remoteid)}]</span>
&nbsp;is {remote.status}
<If condition={remote.status != "connecting"}>
<div
className="button is-wave-green is-outlined is-small"
<Button
className="secondary small connect"
onClick={() => this.clickConnectRemote(remote.remoteid)}
>
connect now
</div>
</Button>
</If>
</div>
</If>

View File

@ -95,33 +95,6 @@
height: 100%;
overflow-x: hidden;
.rendermode-tag {
position: absolute;
top: 0;
right: 0;
background-color: rgba(78, 154, 6, 0.65);
color: var(--app-black);
padding: 2px 8px 2px 4px;
border-bottom-left-radius: 5px;
z-index: 10;
&.is-active {
color: var(--app-text-color);
}
.render-mode {
padding-top: 2px;
position: relative;
cursor: pointer;
color: var(--app-text-color);
&:hover {
color: var(--app-text-color);
}
}
}
.window-empty {
display: flex;
align-items: center;
@ -129,11 +102,11 @@
width: 100%;
padding: 10px;
height: 100%;
color: #ccc;
color: var(--app-text-color);
code {
background-color: transparent;
color: #4e9a06;
color: var(--term-green);
}
&.should-fade {
@ -142,44 +115,6 @@
}
}
.share-tag {
color: var(--app-text-color);
position: absolute;
top: 0;
left: 40%;
background-color: darken(rgb(0, 177, 10), 20%);
padding: 2px 8px 2px 4px;
z-index: 11;
/* border-radius: 0 0 5px 5px; */
opacity: 0.8;
display: flex;
flex-direction: column;
.share-tag-link {
margin-top: 10px;
display: none;
}
&:hover {
.share-tag-title {
font-weight: bold;
}
opacity: 1;
padding: 20px;
width: 250px;
border: 1px solid #ccc;
border-top: 0;
.share-tag-link {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
}
}
.filter-running {
position: relative;
display: flex;

View File

@ -694,20 +694,6 @@ class ScreenWindowView extends React.Component<{ session: Session; screen: Scree
let renderMode = this.renderMode.get();
return (
<div className="window-view" ref={this.windowViewRef} style={{ width: this.props.width }}>
<div
key="rendermode-tag"
className={cn("rendermode-tag", { "is-active": isActive })}
style={{ display: "none" }}
>
<div className="render-mode" onClick={this.toggleRenderMode}>
<If condition={renderMode == "normal"}>
<i title="collapse" className="fa-sharp fa-solid fa-arrows-to-line" />
</If>
<If condition={renderMode == "collapsed"}>
<i title="expand" className="fa-sharp fa-solid fa-arrows-from-line" />
</If>
</div>
</div>
<If condition={lines.length == 0}>
<If condition={screen.nextLineNum.get() == 1}>
<NewTabSettings screen={screen} />
@ -718,40 +704,13 @@ class ScreenWindowView extends React.Component<{ session: Session; screen: Scree
<div key="window-empty" className={cn("window-empty")}>
<div>
<code className="text-standard">
[workspace="{session.name.get()}" screen="{screen.name.get()}"]
[workspace="{session.name.get()}" tab="{screen.name.get()}"]
</code>
</div>
</div>
</div>
</If>
</If>
<If condition={screen.isWebShared()}>
<div key="share-tag" className="share-tag">
<If condition={this.shareCopied.get()}>
<div className="copied-indicator" />
</If>
<div className="share-tag-title">
<i title="archived" className="fa-sharp fa-solid fa-share-nodes" /> web shared
</div>
<div className="share-tag-link">
<div className="button is-wave-green is-outlined is-small" onClick={this.copyShareLink}>
<span>copy link</span>
<span className="icon">
<i className="fa-sharp fa-solid fa-copy" />
</span>
</div>
<div
className="button is-wave-green is-outlined is-small"
onClick={this.openScreenSettings}
>
<span>open settings</span>
<span className="icon">
<i className="fa-sharp fa-solid fa-cog" />
</span>
</div>
</div>
</div>
</If>
<If condition={lines.length > 0}>
<LinesView
screen={screen}

View File

@ -72,9 +72,6 @@ class ScreenTab extends React.Component<
ref={this.tabRef}
value={screen}
id={"screentab-" + screen.screenId}
whileDrag={{
backgroundColor: "rgba(13, 13, 13, 0.85)",
}}
data-screenid={screen.screenId}
className={cn(
"screen-tab",

View File

@ -99,6 +99,7 @@
flex-direction: row;
position: relative;
border-top: 2px solid transparent;
background: var(--app-bg-color);
.background {
// This applies a transparency mask to the background color, as set above, so that it will blend with whatever the theme's background color is.

View File

@ -70,10 +70,6 @@ class RightSidebarModel implements SidebarModel {
}
getCollapsed(): boolean {
// disable right sidebar in production
if (!this.globalModel.isDev) {
return true;
}
const clientData = this.globalModel.clientData.get();
const collapsed = clientData?.clientopts?.rightsidebar?.collapsed;
if (this.isDragging.get()) {

View File

@ -62,7 +62,7 @@
right: 12rem;
border-radius: 5px;
background-color: var(--form-element-secondary-color);
color: var(--app-error-color);
color: var(--app-text-disabled-color);
z-index: 1;
padding: 0 0.8em;
font-size: 0.8em;

View File

@ -797,12 +797,16 @@ declare global {
};
type FileInfoType = {
type: string;
name: string;
size: number;
modts: number;
isdir: boolean;
perm: number;
notfound: boolean;
modestr?: string;
path?: string;
outputpos?: number;
};
type ExtBlob = Blob & {

View File

@ -11,9 +11,11 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"reflect"
"sync"
"time"
"github.com/wavetermdev/waveterm/waveshell/pkg/base"
"github.com/wavetermdev/waveterm/waveshell/pkg/wlog"
@ -58,6 +60,7 @@ const (
WriteFileReadyPacketStr = "writefileready" // rpc-response
WriteFileDonePacketStr = "writefiledone" // rpc-response
FileDataPacketStr = "filedata"
FileStatPacketStr = "filestat"
LogPacketStr = "log" // logging packet (sent from waveshell back to server)
ShellStatePacketStr = "shellstate"
@ -112,6 +115,7 @@ func init() {
TypeStrToFactory[WriteFileDonePacketStr] = reflect.TypeOf(WriteFileDonePacketType{})
TypeStrToFactory[LogPacketStr] = reflect.TypeOf(LogPacketType{})
TypeStrToFactory[ShellStatePacketStr] = reflect.TypeOf(ShellStatePacketType{})
TypeStrToFactory[FileStatPacketStr] = reflect.TypeOf(FileStatPacketType{})
var _ RpcPacketType = (*RunPacketType)(nil)
var _ RpcPacketType = (*GetCmdPacketType)(nil)
@ -379,6 +383,51 @@ func MakeReInitPacket() *ReInitPacketType {
return &ReInitPacketType{Type: ReInitPacketStr}
}
type FileStatPacketType struct {
Type string `json:"type"`
Name string `json:"name"`
Size int64 `json:"size"`
ModTs time.Time `json:"modts"`
IsDir bool `json:"isdir"`
Perm int `json:"perm"`
ModeStr string `json:"modestr"`
Error string `json:"error"`
Done bool `json:"done"`
RespId string `json:"respid"`
Path string `json:"path"`
}
func (*FileStatPacketType) GetType() string {
return FileStatPacketStr
}
func (p *FileStatPacketType) GetResponseDone() bool {
return p.Done
}
func (p *FileStatPacketType) GetResponseId() string {
return p.RespId
}
func MakeFileStatPacketType() *FileStatPacketType {
return &FileStatPacketType{Type: FileStatPacketStr}
}
func MakeFileStatPacketFromFileInfo(finfo fs.FileInfo, err string, done bool) *FileStatPacketType {
resp := MakeFileStatPacketType()
resp.Error = err
resp.Done = done
resp.IsDir = finfo.IsDir()
resp.Name = finfo.Name()
resp.Size = finfo.Size()
resp.ModTs = finfo.ModTime()
resp.Perm = int(finfo.Mode().Perm())
resp.ModeStr = finfo.Mode().String()
return resp
}
type StreamFilePacketType struct {
Type string `json:"type"`
ReqId string `json:"reqid"`

View File

@ -113,6 +113,9 @@ func (b bashShellApi) MakeRcFileStr(pk *packet.RunPacketType) string {
if varDecl.IsExport() || varDecl.IsReadOnly() {
continue
}
if varDecl.IsExtVar {
continue
}
rcBuf.WriteString(BashDeclareStmt(varDecl))
rcBuf.WriteString("\n")
}

View File

@ -205,7 +205,7 @@ func bashParseDeclareOutput(state *packet.ShellState, declareBytes []byte, pvarB
declMap[decl.Name] = decl
}
}
pvarMap := parsePVarOutput(pvarBytes, false)
pvarMap := parseExtVarOutput(pvarBytes, "", "")
utilfn.CombineMaps(declMap, pvarMap)
state.ShellVars = shellenv.SerializeDeclMap(declMap) // this writes out the decls in a canonical order
if firstParseErr != nil {

View File

@ -25,10 +25,13 @@ import (
"github.com/wavetermdev/waveterm/waveshell/pkg/base"
"github.com/wavetermdev/waveterm/waveshell/pkg/packet"
"github.com/wavetermdev/waveterm/waveshell/pkg/shellutil"
"github.com/wavetermdev/waveterm/waveshell/pkg/utilfn"
)
const GetStateTimeout = 5 * time.Second
const GetGitBranchCmdStr = `printf "GITBRANCH %s\x00" "$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"`
const GetK8sContextCmdStr = `printf "K8SCONTEXT %s\x00" "$(kubectl config current-context 2>/dev/null)"`
const GetK8sNamespaceCmdStr = `printf "K8SNAMESPACE %s\x00" "$(kubectl config view --minify --output 'jsonpath={..namespace}' 2>/dev/null)"`
const RunCommandFmt = `%s`
const DebugState = false
@ -239,7 +242,7 @@ func RunSimpleCmdInPty(ecmd *exec.Cmd) ([]byte, error) {
return outputBuf.Bytes(), nil
}
func parsePVarOutput(pvarBytes []byte, isZsh bool) map[string]*DeclareDeclType {
func parseExtVarOutput(pvarBytes []byte, promptOutput string, zmodsOutput string) map[string]*DeclareDeclType {
declMap := make(map[string]*DeclareDeclType)
pvars := bytes.Split(pvarBytes, []byte{0})
for _, pvarBA := range pvars {
@ -251,11 +254,35 @@ func parsePVarOutput(pvarBytes []byte, isZsh bool) map[string]*DeclareDeclType {
if pvarFields[0] == "" {
continue
}
decl := &DeclareDeclType{IsZshDecl: isZsh, Args: "x"}
if pvarFields[1] == "" {
continue
}
decl := &DeclareDeclType{IsExtVar: true}
decl.Name = "PROMPTVAR_" + pvarFields[0]
decl.Value = shellescape.Quote(pvarFields[1])
declMap[decl.Name] = decl
}
if promptOutput != "" {
decl := &DeclareDeclType{IsExtVar: true}
decl.Name = "PROMPTVAR_PS1"
decl.Value = promptOutput
declMap[decl.Name] = decl
}
if zmodsOutput != "" {
var zmods []string
lines := strings.Split(zmodsOutput, "\n")
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) != 2 || fields[0] != "zmodload" {
continue
}
zmods = append(zmods, fields[1])
}
decl := &DeclareDeclType{IsExtVar: true}
decl.Name = ZModsVarName
decl.Value = utilfn.QuickJson(zmods)
declMap[decl.Name] = decl
}
return declMap
}

View File

@ -30,6 +30,21 @@ const BaseZshOpts = ``
const ZshShellVersionCmdStr = `echo zsh v$ZSH_VERSION`
const StateOutputFdNum = 20
const (
ZshSection_Version = iota
ZshSection_Cwd
ZshSection_Env
ZshSection_Mods
ZshSection_Vars
ZshSection_Aliases
ZshSection_Fpath
ZshSection_Funcs
ZshSection_PVars
ZshSection_Prompt
ZshSection_NumFieldsExpected // must be last
)
// TODO these need updating
const RunZshSudoCommandFmt = `sudo -n -C %d zsh /dev/fd/%d`
const RunZshSudoPasswordCommandFmt = `cat /dev/fd/%d | sudo -k -S -C %d zsh -c "echo '[from-mshell]'; exec %d>&-; zsh /dev/fd/%d < /dev/fd/%d"`
@ -54,6 +69,7 @@ var ZshIgnoreVars = map[string]bool{
"SHLVL": true,
"TTY": true,
"ZDOTDIR": true,
"PPID": true,
"epochtime": true,
"langinfo": true,
"keymaps": true,
@ -77,6 +93,8 @@ var ZshIgnoreVars = map[string]bool{
"funcsourcetrace": true,
"funcstack": true,
"functrace": true,
"nameddirs": true,
"userdirs": true,
"parameters": true,
"commands": true,
"functions": true,
@ -86,6 +104,25 @@ var ZshIgnoreVars = map[string]bool{
"_comps": true,
"_patcomps": true,
"_postpatcomps": true,
// zsh/system
"errnos": true,
"sysparams": true,
// zsh/curses
"ZCURSES_COLORS": true,
"ZCURSES_COLOR_PAIRS": true,
"zcurses_attrs": true,
"zcurses_colors": true,
"zcurses_keycodes": true,
"zcurses_windows": true,
// not listed, but we also exclude all ZFTP_* variables
}
var ZshIgnoreFuncs = map[string]bool{
"zftp_chpwd": true,
"zftp_progress": true,
}
// only options we restore (other than ZshForceOptions)
@ -131,11 +168,13 @@ var ZshUnsetVars = []string{
"ZSH_EXECUTION_STRING",
}
var ZshLoadMods = []string{
"zsh/parameter",
"zsh/langinfo",
var ZshForceLoadMods = map[string]bool{
"zsh/parameter": true,
"zsh/langinfo": true,
}
const ZModsVarName = "WAVESTATE_ZMODS"
// do not use these directly, call GetLocalMajorVersion()
var localZshMajorVersionOnce = &sync.Once{}
var localZshMajorVersion = ""
@ -273,14 +312,29 @@ func (z zshShellApi) MakeRcFileStr(pk *packet.RunPacketType) string {
rcBuf.WriteString(fmt.Sprintf("unsetopt %s\n", optName))
}
}
for _, modName := range ZshLoadMods {
for modName := range ZshForceLoadMods {
rcBuf.WriteString(fmt.Sprintf("zmodload %s\n", modName))
}
modDecl := getDeclByName(varDecls, ZModsVarName)
if modDecl != nil {
modsArr := utilfn.QuickParseJson[[]string](modDecl.Value)
for _, modName := range modsArr {
if !ZshForceLoadMods[modName] {
rcBuf.WriteString(fmt.Sprintf("zmodload %s\n", modName))
}
}
}
var postDecls []*shellenv.DeclareDeclType
for _, varDecl := range varDecls {
if ZshIgnoreVars[varDecl.Name] {
continue
}
if strings.HasPrefix(varDecl.Name, "ZFTP_") {
continue
}
if varDecl.IsExtVar {
continue
}
if ZshUniqueArrayVars[varDecl.Name] && !varDecl.IsUniqueArray() {
varDecl.AddFlag("U")
}
@ -332,6 +386,9 @@ func (z zshShellApi) MakeRcFileStr(pk *packet.RunPacketType) string {
rcBuf.WriteString("# error decoding zsh functions\n")
} else {
for fnKey, fnValue := range fnMap {
if ZshIgnoreFuncs[fnKey.ParamName] {
continue
}
if fnValue == ZshFnAutoLoad {
rcBuf.WriteString(fmt.Sprintf("autoload %s\n", shellescape.Quote(fnKey.ParamName)))
} else {
@ -411,6 +468,8 @@ pwd;
printf "[%SECTIONSEP%]";
env -0;
printf "[%SECTIONSEP%]";
zmodload -L
printf "[%SECTIONSEP%]";
typeset -p +H -m '*';
printf "[%SECTIONSEP%]";
for var in "${(@k)aliases}"; do
@ -448,10 +507,16 @@ for var in "${(@k)dis_functions_source}"; do
done
printf "[%SECTIONSEP%]";
[%GITBRANCH%]
[%K8SCONTEXT%]
[%K8SNAMESPACE%]
printf "[%SECTIONSEP%]";
print -P "$PS1"
`
cmd = strings.TrimSpace(cmd)
cmd = strings.ReplaceAll(cmd, "[%ZSHVERSION%]", ZshShellVersionCmdStr)
cmd = strings.ReplaceAll(cmd, "[%GITBRANCH%]", GetGitBranchCmdStr)
cmd = strings.ReplaceAll(cmd, "[%K8SCONTEXT%]", GetK8sContextCmdStr)
cmd = strings.ReplaceAll(cmd, "[%K8SNAMESPACE%]", GetK8sNamespaceCmdStr)
cmd = strings.ReplaceAll(cmd, "[%PARTSEP%]", utilfn.ShellHexEscape(string(sectionSeparator[0:len(sectionSeparator)-1])))
cmd = strings.ReplaceAll(cmd, "[%SECTIONSEP%]", utilfn.ShellHexEscape(string(sectionSeparator)))
cmd = strings.ReplaceAll(cmd, "[%OUTPUTFD%]", fmt.Sprintf("/dev/fd/%d", fdNum))
@ -599,6 +664,9 @@ func ParseZshFunctions(fpathArr []string, fnBytes []byte, partSeparator []byte)
if fnName == "zshexit" {
continue
}
if ZshIgnoreFuncs[fnName] {
continue
}
if fnType == "functions" || fnType == "dis_functions" {
fnBody[ZshParamKey{ParamType: fnType, ParamName: fnName}] = fnValue
}
@ -609,10 +677,13 @@ func ParseZshFunctions(fpathArr []string, fnBytes []byte, partSeparator []byte)
// ok, so the trick here is that we want to only include functions that are *not* autoloaded
// the ones that are pending autoloading or come from a source file in fpath, can just be set to autoload
for fnKey := range fnBody {
var inFpath bool
source := fnSource[fnKey.ParamName]
if isSourceFileInFpath(fpathArr, source) {
fnBody[fnKey] = ZshFnAutoLoad
} else if strings.TrimSpace(fnBody[fnKey]) == ZshAutoloadFnBody {
if source != "" {
inFpath = isSourceFileInFpath(fpathArr, source)
}
isAutoloadFnBody := strings.TrimSpace(fnBody[fnKey]) == ZshAutoloadFnBody
if inFpath || isAutoloadFnBody {
fnBody[fnKey] = ZshFnAutoLoad
}
}
@ -639,11 +710,11 @@ func (z zshShellApi) ParseShellStateOutput(outputBytes []byte) (*packet.ShellSta
versionStr := string(outputBytes[0:firstZeroIdx])
sectionSeparator := outputBytes[firstZeroIdx+1 : firstDZeroIdx+2]
partSeparator := sectionSeparator[0 : len(sectionSeparator)-1]
// 8 fields: version [0], cwd [1], env [2], vars [3], aliases [4], fpath [5], functions [6], pvars [7]
fields := bytes.Split(outputBytes, sectionSeparator)
if len(fields) != 8 {
// sections: see ZshSection_* consts
sections := bytes.Split(outputBytes, sectionSeparator)
if len(sections) != ZshSection_NumFieldsExpected {
base.Logf("invalid -- numfields\n")
return nil, fmt.Errorf("invalid zsh shell state output, wrong number of fields, fields=%d", len(fields))
return nil, fmt.Errorf("invalid zsh shell state output, wrong number of sections, section=%d", len(sections))
}
rtn := &packet.ShellState{}
rtn.Version = strings.TrimSpace(versionStr)
@ -653,10 +724,10 @@ func (z zshShellApi) ParseShellStateOutput(outputBytes []byte) (*packet.ShellSta
if _, _, err := packet.ParseShellStateVersion(rtn.Version); err != nil {
return nil, fmt.Errorf("invalid zsh shell state output, invalid version: %v", err)
}
cwdStr := stripNewLineChars(string(fields[1]))
cwdStr := stripNewLineChars(string(sections[ZshSection_Cwd]))
rtn.Cwd = cwdStr
zshEnv := parseZshEnv(fields[2])
zshDecls, err := parseZshDecls(fields[3])
zshEnv := parseZshEnv(sections[ZshSection_Env])
zshDecls, err := parseZshDecls(sections[ZshSection_Vars])
if err != nil {
base.Logf("invalid - parsedecls %v\n", err)
return nil, err
@ -666,16 +737,15 @@ func (z zshShellApi) ParseShellStateOutput(outputBytes []byte) (*packet.ShellSta
decl.ZshEnvValue = zshEnv[decl.ZshBoundScalar]
}
}
aliasMap := parseZshAliasStateOutput(fields[4], partSeparator)
aliasMap := parseZshAliasStateOutput(sections[ZshSection_Aliases], partSeparator)
rtn.Aliases = string(EncodeZshMap(aliasMap))
fpathStr := stripNewLineChars(string(string(fields[5])))
fpathStr := stripNewLineChars(string(string(sections[ZshSection_Fpath])))
fpathArr := strings.Split(fpathStr, ":")
zshFuncs := ParseZshFunctions(fpathArr, fields[6], partSeparator)
zshFuncs := ParseZshFunctions(fpathArr, sections[ZshSection_Funcs], partSeparator)
rtn.Funcs = string(EncodeZshMap(zshFuncs))
pvarMap := parsePVarOutput(fields[7], true)
pvarMap := parseExtVarOutput(sections[ZshSection_PVars], string(sections[ZshSection_Prompt]), string(sections[ZshSection_Mods]))
utilfn.CombineMaps(zshDecls, pvarMap)
rtn.ShellVars = shellenv.SerializeDeclMap(zshDecls)
base.Logf("parse shellstate done\n")
return rtn, nil
}

View File

@ -22,6 +22,7 @@ const (
type DeclareDeclType struct {
IsZshDecl bool
IsExtVar bool // set for "special" wave internal variables
Args string
Name string
@ -36,31 +37,31 @@ type DeclareDeclType struct {
}
func (d *DeclareDeclType) IsExport() bool {
return strings.Index(d.Args, "x") >= 0
return strings.Contains(d.Args, "x")
}
func (d *DeclareDeclType) IsReadOnly() bool {
return strings.Index(d.Args, "r") >= 0
return strings.Contains(d.Args, "r")
}
func (d *DeclareDeclType) IsZshScalarBound() bool {
return strings.Index(d.Args, "T") >= 0
return strings.Contains(d.Args, "T")
}
func (d *DeclareDeclType) IsArray() bool {
return strings.Index(d.Args, "a") >= 0
return strings.Contains(d.Args, "a")
}
func (d *DeclareDeclType) IsAssocArray() bool {
return strings.Index(d.Args, "A") >= 0
return strings.Contains(d.Args, "A")
}
func (d *DeclareDeclType) IsUniqueArray() bool {
return d.IsArray() && strings.Index(d.Args, "U") >= 0
return d.IsArray() && strings.Contains(d.Args, "U")
}
func (d *DeclareDeclType) AddFlag(flag string) {
if strings.Index(d.Args, flag) >= 0 {
if strings.Contains(d.Args, flag) {
return
}
d.Args += flag
@ -101,13 +102,13 @@ func (d *DeclareDeclType) SortZshFlags() {
}
func (d *DeclareDeclType) DataType() string {
if strings.Index(d.Args, "a") >= 0 {
if strings.Contains(d.Args, "a") {
return DeclTypeArray
}
if strings.Index(d.Args, "A") >= 0 {
if strings.Contains(d.Args, "A") {
return DeclTypeAssocArray
}
if strings.Index(d.Args, "i") >= 0 {
if strings.Contains(d.Args, "i") {
return DeclTypeInt
}
return DeclTypeNormal
@ -124,7 +125,15 @@ func FindVarDecl(decls []*DeclareDeclType, name string) *DeclareDeclType {
// NOTE Serialize no longer writes the final null byte
func (d *DeclareDeclType) Serialize() []byte {
if d.IsZshDecl {
if d.IsExtVar {
parts := []string{
"e1",
d.Args,
d.Name,
d.Value,
}
return utilfn.EncodeStringArray(parts)
} else if d.IsZshDecl {
d.SortZshFlags()
parts := []string{
"z1",
@ -149,6 +158,15 @@ func (d *DeclareDeclType) Serialize() []byte {
// return []byte(rtn)
}
func (d *DeclareDeclType) UnescapedValue() string {
if d.IsExtVar {
return d.Value
}
ectx := simpleexpand.SimpleExpandContext{}
rtn, _ := simpleexpand.SimpleExpandPartialWord(ectx, d.Value, false)
return rtn
}
func DeclsEqual(compareName bool, d1 *DeclareDeclType, d2 *DeclareDeclType) bool {
if d1.IsExport() != d2.IsExport() {
return false
@ -164,7 +182,8 @@ func DeclsEqual(compareName bool, d1 *DeclareDeclType, d2 *DeclareDeclType) bool
// envline should be valid
func parseDeclLine(envLineBytes []byte) *DeclareDeclType {
if utilfn.EncodedStringArrayHasFirstKey(envLineBytes, "z1") {
esFirstVal := utilfn.EncodedStringArrayGetFirstVal(envLineBytes)
if esFirstVal == "z1" {
parts, err := utilfn.DecodeStringArray(envLineBytes)
if err != nil {
return nil
@ -180,7 +199,7 @@ func parseDeclLine(envLineBytes []byte) *DeclareDeclType {
ZshBoundScalar: parts[4],
ZshEnvValue: parts[5],
}
} else if utilfn.EncodedStringArrayHasFirstKey(envLineBytes, "b1") {
} else if esFirstVal == "b1" {
parts, err := utilfn.DecodeStringArray(envLineBytes)
if err != nil {
return nil
@ -193,8 +212,25 @@ func parseDeclLine(envLineBytes []byte) *DeclareDeclType {
Name: parts[2],
Value: parts[3],
}
} else if esFirstVal == "e1" {
parts, err := utilfn.DecodeStringArray(envLineBytes)
if err != nil {
return nil
}
if len(parts) != 4 {
return nil
}
return &DeclareDeclType{
IsExtVar: true,
Args: parts[1],
Name: parts[2],
Value: parts[3],
}
} else if esFirstVal == "p1" {
// deprecated
return nil
}
// legacy decoding (v0)
// legacy decoding (v0) (not an encoded string array)
envLine := string(envLineBytes)
eqIdx := strings.Index(envLine, "=")
if eqIdx == -1 {

View File

@ -7,6 +7,7 @@ import (
"bytes"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math"
@ -400,7 +401,7 @@ func DecodeStringArray(barr []byte) ([]string, error) {
return rtn, nil
}
func EncodedStringArrayHasFirstKey(encoded []byte, firstKey string) bool {
func EncodedStringArrayHasFirstVal(encoded []byte, firstKey string) bool {
firstKeyBytes := NullEncodeStr(firstKey)
if !bytes.HasPrefix(encoded, firstKeyBytes) {
return false
@ -411,6 +412,18 @@ func EncodedStringArrayHasFirstKey(encoded []byte, firstKey string) bool {
return false
}
// on encoding error returns ""
// this is used to perform logic on first value without decoding the entire array
func EncodedStringArrayGetFirstVal(encoded []byte) string {
sepIdx := bytes.IndexByte(encoded, nullEncodeSepByte)
if sepIdx == -1 {
str, _ := NullDecodeStr(encoded)
return str
}
str, _ := NullDecodeStr(encoded[0:sepIdx])
return str
}
// encodes a string, removing null/zero bytes (and separators '|')
// a zero byte is encoded as "\0", a '\' is encoded as "\\", sep is encoded as "\s"
// allows for easy double splitting (first on \x00, and next on "|")
@ -520,3 +533,22 @@ func CombineStrArrays(sarr1 []string, sarr2 []string) []string {
}
return rtn
}
func QuickJson(v interface{}) string {
barr, _ := json.Marshal(v)
return string(barr)
}
func QuickParseJson[T any](s string) T {
var v T
_ = json.Unmarshal([]byte(s), &v)
return v
}
func StrArrayToMap(sarr []string) map[string]bool {
m := make(map[string]bool)
for _, s := range sarr {
m[s] = true
}
return m
}

View File

@ -677,6 +677,40 @@ func HandleRunCommand(w http.ResponseWriter, r *http.Request) {
WriteJsonSuccess(w, update)
}
func CheckIsDir(dirHandler http.Handler, fileHandler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
configPath := r.URL.Path
configAbsPath, err := filepath.Abs(configPath)
if err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("error getting absolute path", err)))
return
}
configBaseDir := path.Join(scbase.GetWaveHomeDir(), "config")
configFullPath := path.Join(scbase.GetWaveHomeDir(), configAbsPath)
if !strings.HasPrefix(configFullPath, configBaseDir) {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("error: path is not in config folder")))
return
}
fstat, err := os.Stat(configFullPath)
if errors.Is(err, fs.ErrNotExist) {
w.WriteHeader(404)
w.Write([]byte(fmt.Sprintf("file not found: ", configAbsPath)))
return
} else if err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("file stat err", err)))
return
}
if fstat.IsDir() {
AuthKeyMiddleWare(dirHandler).ServeHTTP(w, r)
} else {
AuthKeyMiddleWare(fileHandler).ServeHTTP(w, r)
}
})
}
func AuthKeyMiddleWare(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqAuthKey := r.Header.Get("X-AuthKey")
@ -857,6 +891,39 @@ func doShutdown(reason string) {
})
}
func configDirHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("running?")
configPath := r.URL.Path
configFullPath := path.Join(scbase.GetWaveHomeDir(), configPath)
dirFile, err := os.Open(configFullPath)
if err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("error opening specified dir: ", err)))
return
}
entries, err := dirFile.Readdir(0)
if err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("error getting files: ", err)))
return
}
var files []*packet.FileStatPacketType
for index := 0; index < len(entries); index++ {
curEntry := entries[index]
curFile := packet.MakeFileStatPacketFromFileInfo(curEntry, "", false)
files = append(files, curFile)
}
dirListJson, err := json.Marshal(files)
if err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("json err: ", err)))
return
}
w.WriteHeader(200)
w.Header().Set("Content-Type", "application/json")
w.Write(dirListJson)
}
func main() {
scbase.BuildTime = BuildTime
scbase.WaveVersion = WaveVersion
@ -953,7 +1020,9 @@ func main() {
gr.HandleFunc("/api/write-file", AuthKeyWrap(HandleWriteFile)).Methods("POST")
configPath := path.Join(scbase.GetWaveHomeDir(), "config") + "/"
log.Printf("[wave] config path: %q\n", configPath)
gr.PathPrefix("/config/").Handler(AuthKeyMiddleWare(http.StripPrefix("/config/", http.FileServer(http.Dir(configPath)))))
isFileHandler := http.StripPrefix("/config/", http.FileServer(http.Dir(configPath)))
isDirHandler := http.HandlerFunc(configDirHandler)
gr.PathPrefix("/config/").Handler(CheckIsDir(isDirHandler, isFileHandler))
serverAddr := MainServerAddr
if scbase.IsDevMode() {

View File

@ -3825,7 +3825,8 @@ func HistoryViewAllCommand(ctx context.Context, pk *scpacket.FeCommandPacketType
if pk.Kwargs["fromts"] != "" {
fromTs, err := resolvePosInt(pk.Kwargs["fromts"], 0)
if err != nil {
return nil, fmt.Errorf("invalid fromts (must be unixtime (milliseconds): %v", err)
// no error here anymore (otherwise it jams up the frontend, just ignore and set to 0)
opts.FromTs = 0
}
if fromTs > 0 {
opts.FromTs = int64(fromTs)

View File

@ -214,6 +214,7 @@ var literalRtnStateCommands = []string{
"enable",
"disable",
"function",
"zmodload",
}
func getCallExprLitArg(callExpr *syntax.CallExpr, argNum int) string {

View File

@ -1784,16 +1784,22 @@ func makeTermOpts(runPk *packet.RunPacketType) sstore.TermOpts {
return sstore.TermOpts{Rows: int64(runPk.TermOpts.Rows), Cols: int64(runPk.TermOpts.Cols), FlexRows: runPk.TermOpts.FlexRows, MaxPtySize: DefaultMaxPtySize}
}
// returns (ok, currentPSC)
// if ok is true, currentPSC will be nil
// if ok is false, currentPSC will be the existing pending state command (not nil)
func (msh *MShellProc) testAndSetPendingStateCmd(screenId string, rptr sstore.RemotePtrType, newCK *base.CommandKey) (bool, *base.CommandKey) {
// returns (ok, rct)
// if ok is true, rct will be nil
// if ok is false, rct will be the existing pending state command (not nil)
func (msh *MShellProc) testAndSetPendingStateCmd(screenId string, rptr sstore.RemotePtrType, newCK *base.CommandKey) (bool, *RunCmdType) {
key := pendingStateKey{ScreenId: screenId, RemotePtr: rptr}
msh.Lock.Lock()
defer msh.Lock.Unlock()
ck, found := msh.PendingStateCmds[key]
if found {
return false, &ck
// we don't call GetRunningCmd here because we already hold msh.Lock
rct := msh.RunningCmds[ck]
if rct != nil {
return false, rct
}
// ok, so rct is nil (that's strange). allow command to proceed, but log
log.Printf("[warning] found pending state cmd with no running cmd: %s\n", ck)
}
if newCK != nil {
msh.PendingStateCmds[key] = *newCK
@ -1883,15 +1889,14 @@ func RunCommand(ctx context.Context, rcOpts RunCommandOpts, runPacket *packet.Ru
if runPacket.ReturnState {
newPSC = &runPacket.CK
}
ok, existingPSC := msh.testAndSetPendingStateCmd(screenId, remotePtr, newPSC)
ok, existingRct := msh.testAndSetPendingStateCmd(screenId, remotePtr, newPSC)
if !ok {
rct := msh.GetRunningCmd(*existingPSC)
if rct.Ephemeral {
if existingRct.Ephemeral {
// if the existing command is ephemeral, we cancel it and continue
rct.EphCancled.Store(true)
existingRct.EphCancled.Store(true)
} else {
line, _, err := sstore.GetLineCmdByLineId(ctx, screenId, existingPSC.GetCmdId())
return nil, nil, makePSCLineError(*existingPSC, line, err)
line, _, err := sstore.GetLineCmdByLineId(ctx, screenId, existingRct.CK.GetCmdId())
return nil, nil, makePSCLineError(existingRct.CK, line, err)
}
}
if newPSC != nil {

View File

@ -751,16 +751,17 @@ func FeStateFromShellState(state *packet.ShellState) map[string]string {
}
rtn := make(map[string]string)
rtn["cwd"] = state.Cwd
envMap := shellenv.EnvMapFromState(state)
if envMap["VIRTUAL_ENV"] != "" {
rtn["VIRTUAL_ENV"] = envMap["VIRTUAL_ENV"]
declMap := shellenv.DeclMapFromState(state)
if decl, ok := declMap["VIRTUAL_ENV"]; ok {
rtn["VIRTUAL_ENV"] = decl.UnescapedValue()
}
if envMap["CONDA_DEFAULT_ENV"] != "" {
rtn["CONDA_DEFAULT_ENV"] = envMap["CONDA_DEFAULT_ENV"]
if decl, ok := declMap["CONDA_DEFAULT_ENV"]; ok {
rtn["CONDA_DEFAULT_ENV"] = decl.UnescapedValue()
}
for key, val := range envMap {
if strings.HasPrefix(key, "PROMPTVAR_") && envMap[key] != "" {
rtn[key] = val
for _, decl := range declMap {
// works for both legacy and new IsExtVar decls
if strings.HasPrefix(decl.Name, "PROMPTVAR_") {
rtn[decl.Name] = decl.UnescapedValue()
}
}
_, _, err := packet.ParseShellStateVersion(state.Version)